Repository navigation
refactor(slm-frontend): route the getSlmApiBase() call sites through slmApiClient (#13140) - #13170
Merged
Merged
Conversation
…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>'.
Contributor
✅ SSOT Configuration Compliance: Passing🎉 No hardcoded values detected that have SSOT config equivalents! |
This was referenced Jul 31, 2026
4 of 5 tasks
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
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Second slice of #13140, after PR #13169 (
824f4576b) converted the 23 sites that resolved their base URL throughstores/auth.ts:getApiUrl(). This one takes the 30 sites that already callgetSlmApiBase()— every remaining SLM-backendfetchin the app. #13140 stays open (definition-of-done item 4, the ESLint rule, is not landed here), so this PR deliberately carries noCloseskeyword; 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 inautobot-slm-frontend/src(tests excluded), down from the 73 the first slice measured:getSlmApiBase()getBackendUrl()/-/healthy, Grafana/api/health)utils/ApiClient.ts:221Not every site should become a
get(). Two have a genuine transport reason to keep theResponseobject, and userawRequestrather than the JSON helpers:usePrometheusMetrics.fetchPrometheusExportreads/performance/metrics/prometheus, which returns the Prometheus text exposition format.get()callsresponse.json()and would reject on it.useSlmWebSocket.checkBackendHealthis 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
rawRequestbecause the rendered error must not change:ToolsView(6),FleetToolsTab(2),RolesView.apiFetch,InfrastructureWizard.executeandusePerformanceMonitoring.apiRequestall surface the backend'sdetailbody (or, forusePerformanceMonitoring, its ownHTTP <n>: <raw body text>string) directly to the operator.post()/get()would flatten those intoHTTP <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()introducesslmApiClient.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 newPOLLED_READ_MAX_RETRIESis 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.apiRequestroutes throughrawRequestfor the same reason (plus its error-string contract) —startPolling()re-issuesfetchOverviewon 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()introducesThese 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.tsgives 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-coded3000.Two defects fixed on the way
Bearer null— 7 sites.InfrastructureWizard×4,InfrastructureView,DeploymentWizardbuiltAuthorization: `Bearer ${sessionStorage.getItem('slm_access_token')}`unconditionally;AgentsView×3 did the same withauthStore.token. With no session the interpolation stringifiesnulland the literal headerBearer nullwent 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 letshandleUnauthorizedtell "session rejected" (clear + redirect) apart from "never had one" (log only).A fallback that never ran.
DeploymentWizard.fetchRoleshas acatchthat rebuilds the role list fromNODE_ROLE_METADATA"if API fails" — but the success path wasif (response.ok)with no else, andfetchonly rejects at the network level. So an HTTP failure fell through both branches:rolesstayed 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.clearAlertswasawait fetch(url, { method: 'DELETE', headers })with theResponsenever 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 newenkey,monitoring.alertsMonitor.clearFailed; no bare@added).A fourth base-URL spelling retired
useSlmWebSocket.ts:188hand-built${config.apiBaseUrl}/api/health. That is exactlygetSlmApiBase(), 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:66seeds 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 at824f4576bwith an identical failing set (zero).npm run test:unitnpm run type-checknpm run lintNon-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.The 8 that pass on both sides are stated as regression guards, not defect proofs: endpoint paths, the JSON payload shape,
POLL_OPTSstaying 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
Authorizationheader 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 onceREMOTE_EXEC_TIMEOUT_MSis spent — not that a client method was called.Deferred, and why
fetch(/axios.createoutside the client. It cannot land while the 17getBackendUrl()sites (tech-debt(slm-frontend): 7 components bypass useAutobotApi with their own transport, losing the autobot_access_token fallback #13079) and the 3 private axios instances remain.useOrchestration,useOrchestrationManagement,useExternalAgents) — they belong withslmApiCompat, which is a different shape of change.getApiUrl()instores/auth.tsremains for its two display-only consumers (APISettings.vue:25,BackendSettings.vue:63,277), which report host origin rather than API base — unchanged from the first slice.After this PR, zero SLM-backend raw
fetchcalls remain inautobot-slm-frontend/src.Model Used
claude-opus-4-6-20260213