fix: page past the 100-row cap in application pickers
Some checks failed
CI / backend (push) Failing after 7s
CI / naming (push) Successful in 2s
CI / frontend (push) Successful in 9s
CI / migrations-mysql (push) Failing after 8s

get_pagination_params clamps perpage to MAX_PAGE_SIZE (100) and reports
nothing about having done so, so a caller asking for perpage: 1000 gets the
first 100 rows and a success response. Every picker built that way looked
complete and was not.

Found on a live site with 126 active applications: the 26 sorting last were
absent from the knowledge-base topic dropdown, so an article could not be
filed against them. Nothing was wrong with those application records, and
editing them could never have helped.

Adds fetchAllPages() to the api module, generalizing the one call site that
already handled this correctly (modelsApi.listAll), and points the four
application pickers at a new applicationsApi.listAll(): the KB article form,
the KB list's topic filter, the notification form, and the report filter
builder.

Lists that render a page at a time are untouched - they page for a reason.
Other callers still asking for more than 100 rows of vendors, locations,
models, subnets and the rest are latent: correct only while those tables stay
under 100, and silent on the day they do not.
This commit is contained in:
cproudlock
2026-08-17 14:06:35 -04:00
parent 741dda5be7
commit 9c1c6c5729
5 changed files with 54 additions and 24 deletions

View File

@@ -50,6 +50,30 @@ api.interceptors.response.use(
export default api export default api
// The backend clamps perpage to MAX_PAGE_SIZE (100) and says nothing about it,
// so asking for `perpage: 1000` silently returns the first 100 rows and drops
// the rest. A picker built that way looks complete and is not: with 126
// applications on a live site, the 26 sorting last were simply unselectable.
//
// Use this wherever a control needs the WHOLE list (dropdowns, pickers, label
// batches) rather than a page of it. Returns the full array directly, not an
// axios response. Anything that renders a paged table should keep calling
// list() with a real page number instead.
export async function fetchAllPages(path, params = {}) {
const first = await api.get(path, { params: { ...params, perpage: 100, page: 1 } })
let items = first.data.data || []
const totalpages = first.data.meta?.pagination?.totalpages || 1
if (totalpages > 1) {
const rest = await Promise.all(
Array.from({ length: totalpages - 1 }, (_, i) =>
api.get(path, { params: { ...params, perpage: 100, page: i + 2 } })
)
)
rest.forEach(response => { items = items.concat(response.data.data || []) })
}
return items
}
// Auth API // Auth API
export const authApi = { export const authApi = {
login(username, password) { login(username, password) {
@@ -388,19 +412,8 @@ export const modelsApi = {
// Backend caps perpage at 100, so page through every model. Returns the // Backend caps perpage at 100, so page through every model. Returns the
// full array directly (not an axios response). Use in forms whose model // full array directly (not an axios response). Use in forms whose model
// dropdown must include the editing record's model regardless of page. // dropdown must include the editing record's model regardless of page.
async listAll() { listAll(params = {}) {
const first = await api.get('/models', { params: { perpage: 100, page: 1 } }) return fetchAllPages('/models', params)
let items = first.data.data || []
const totalpages = first.data.meta?.pagination?.totalpages || 1
if (totalpages > 1) {
const rest = await Promise.all(
Array.from({ length: totalpages - 1 }, (_, i) =>
api.get('/models', { params: { perpage: 100, page: i + 2 } })
)
)
rest.forEach(r => { items = items.concat(r.data.data || []) })
}
return items
}, },
get(id) { get(id) {
return api.get(`/models/${id}`) return api.get(`/models/${id}`)
@@ -468,6 +481,12 @@ export const applicationsApi = {
list(params = {}) { list(params = {}) {
return api.get('/applications', { params }) return api.get('/applications', { params })
}, },
// Every application, paged past the backend's 100-row cap. The catalogue is
// already over 100 entries on a live site, so any picker offering "all
// applications" must use this and not list({ perpage: <big number> }).
listAll(params = {}) {
return fetchAllPages('/applications', params)
},
get(id) { get(id) {
return api.get(`/applications/${id}`) return api.get(`/applications/${id}`)
}, },

View File

@@ -270,8 +270,10 @@ async function loadFilterOptions(fields) {
filterOptions.value.locations = response.data.data || [] filterOptions.value.locations = response.data.data || []
} }
if (fields.includes('application') && !filterOptions.value.applications.length) { if (fields.includes('application') && !filterOptions.value.applications.length) {
const response = await applicationsApi.list({ perpage: 100 }) // listAll: 100 is the server-side cap, not a generous limit, and the
filterOptions.value.applications = response.data.data || [] // catalogue is past it - a report filtered by a late-alphabet
// application could not be built.
filterOptions.value.applications = await applicationsApi.listAll()
} }
} catch (error) { } catch (error) {
console.error('Error loading filter options:', error) console.error('Error loading filter options:', error)

View File

@@ -106,10 +106,13 @@ const applications = ref([])
onMounted(async () => { onMounted(async () => {
try { try {
// Load applications for topic dropdown // Load applications for topic dropdown
const appsRes = await applicationsApi.list({ perpage: 1000, showhidden: true }) // isactive is the only filter that applies to a topic: // listAll, not list: the backend clamps perpage to 100 without saying so,
// ishidden governs whether an application shows on the tiles page, which // and the catalogue is past that, so a topic sorting late in the alphabet
// says nothing about whether it can be the subject of an article. // was silently missing from this dropdown.
applications.value = appsRes.data.data || [] // isactive is the only filter that applies to a topic: ishidden governs
// whether an application shows on the tiles page, which says nothing about
// whether it can be the subject of an article.
applications.value = await applicationsApi.listAll({ showhidden: true })
// Load article if editing // Load article if editing
if (isEdit.value) { if (isEdit.value) {

View File

@@ -171,10 +171,13 @@ async function loadArticles() {
async function loadTopics() { async function loadTopics() {
try { try {
const response = await applicationsApi.list({ perpage: 1000, showhidden: true }) // isactive is the only filter that applies to a topic: // listAll, not list: the backend clamps perpage to 100 without saying so,
// ishidden governs whether an application shows on the tiles page, which // and the catalogue is past that, so topics sorting late in the alphabet
// says nothing about whether it can be the subject of an article. // were silently missing from this filter.
topics.value = response.data.data || [] // isactive is the only filter that applies to a topic: ishidden governs
// whether an application shows on the tiles page, which says nothing about
// whether it can be the subject of an article.
topics.value = await applicationsApi.listAll({ showhidden: true })
} catch (error) { } catch (error) {
console.error('Error loading topics:', error) console.error('Error loading topics:', error)
} }

View File

@@ -318,7 +318,10 @@ onMounted(async () => {
const [typesRes, buRes, appsRes, tzRes] = await Promise.all([ const [typesRes, buRes, appsRes, tzRes] = await Promise.all([
notificationsApi.types.list(), notificationsApi.types.list(),
businessUnitsApi.list().catch(() => ({ data: { data: [] } })), businessUnitsApi.list().catch(() => ({ data: { data: [] } })),
applicationsApi.list({ perpage: 500 }).catch(() => ({ data: { data: [] } })), // listAll: perpage is clamped to 100 server-side, and the catalogue is
// already past that, so this dropdown was missing its tail.
applicationsApi.listAll().then(items => ({ data: { data: items } }))
.catch(() => ({ data: { data: [] } })),
settingsApi.get('site_timezone').catch(() => null) settingsApi.get('site_timezone').catch(() => null)
]) ])