Skip to content

[7257] Add proper pagination to Hosted and Remote Instances pages + N+1 fix for Hosted Instances#7353

Open
n-lark wants to merge 17 commits into
mainfrom
7257-pagination
Open

[7257] Add proper pagination to Hosted and Remote Instances pages + N+1 fix for Hosted Instances#7353
n-lark wants to merge 17 commits into
mainfrom
7257-pagination

Conversation

@n-lark
Copy link
Copy Markdown
Contributor

@n-lark n-lark commented May 27, 2026

Description

See #7257 (comment)

Related Issue(s)

Resolves #7257

Checklist

  • I have read the contribution guidelines
  • Suitable unit/system level tests have been added and they pass
  • Documentation has been updated
    • Upgrade instructions
    • Configuration details
    • Concepts
  • Changes flowforge.yml?
    • Issue/PR raised on FlowFuse/helm to update ConfigMap Template
    • Issue/PR raised on FlowFuse/CloudProject to update values for Staging/Production
  • Link to Changelog Entry PR, or note why one is not needed.

Labels

  • Includes a DB migration? -> add the area:migration label

@n-lark n-lark self-assigned this May 27, 2026
@codecov
Copy link
Copy Markdown

codecov Bot commented May 27, 2026

Codecov Report

❌ Patch coverage is 98.57143% with 1 line in your changes missing coverage. Please review.
✅ Project coverage is 76.71%. Comparing base (77cdb58) to head (b5cbd25).

Files with missing lines Patch % Lines
forge/db/models/Device.js 95.45% 1 Missing ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##             main    #7353      +/-   ##
==========================================
+ Coverage   76.65%   76.71%   +0.06%     
==========================================
  Files         410      410              
  Lines       20860    20920      +60     
  Branches     5061     5094      +33     
==========================================
+ Hits        15990    16049      +59     
- Misses       4870     4871       +1     
Flag Coverage Δ
backend 76.71% <98.57%> (+0.06%) ⬆️

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

☔ View full report in Codecov by Sentry.
📢 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.

…t to show total count on search, update specs
…s and filter tests for server-side pagination
@n-lark n-lark marked this pull request as ready for review May 28, 2026 17:20
@n-lark n-lark requested a review from cstns May 28, 2026 17:20
Comment thread forge/db/models/Project.js Outdated
includeSettings = false,
includeMeta = false,
limit = null,
offset = null,
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.

Should we switch to a generic pagination object to house these params, similar to the device model? This method's arguments are getting out of hand really fast.

maybe set it up as an object with default values?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Done. byTeam now takes a pagination object alongside the other opts. Defaults are inline so callers can pass a partial object or skip it entirely.

Comment thread forge/db/models/Project.js Outdated
orderByMostRecentFlows = false,
excludeApplications = null
excludeApplications = null,
withTotal = false
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.

with total could be derived off if we're sending in the pagination object or not

Copy link
Copy Markdown
Contributor Author

@n-lark n-lark Jun 3, 2026

Choose a reason for hiding this comment

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

Yup, gone. withTotal now derives from pagination.page != null inside byTeam. Callers no longer pass it explicitly.

Comment thread forge/routes/api/team.js
properties: {
sort: {
type: 'string',
enum: ['name', 'createdAt', 'updatedAt', 'application.name', 'flowLastUpdatedAt']
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.

pagination params already include sort as an option, was this only so we can type the endpoint more tightly?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Yeah PaginationParams.sort is just a string, so unknown values would silently fall through to the default order. The enum makes the route 400 instead.

Comment thread forge/routes/api/team.js
}

const projects = await app.db.models.Project.byTeam(request.params.teamId, options)
const queryResult = await app.db.models.Project.byTeam(request.params.teamId, options)
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.

this is where we could send the pagination param in

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Yup refactored.

Comment thread forge/routes/api/team.js Outdated
const options = {
includeSettings: true,
limit: request.query.limit,
limit,
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.

we could extract the pagination options similarly to how we do it for devices?
const paginationOptions = app.db.controllers.Device.getDevicePaginationOptions(request)

Copy link
Copy Markdown
Contributor Author

@n-lark n-lark Jun 3, 2026

Choose a reason for hiding this comment

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

Done. New app.db.controllers.Project.getProjectPaginationOptions(request) mirrors the device pattern. ?limit= alone still gets the legacy array.

Alerts.emit('Successfully deleted the device', 'confirmation')
this.deleteLocalCopyOfDevice(device)
this.$emit('delete-device')
if (typeof this.loadDevices === 'function') {
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.

this has me thinking on why we need to do this if we're already deleting the local copy of the device (which should in turn remove it from the ui)

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

deleteLocalCopyOfDevice only removes the row from the in-memory map. Refetching keeps totalRows (server-side count) honest and pulls in a backfill row from the next page so we don't show "29 of 30" with a missing row. Skipping the refetch leaves the footer drifting and rows missing on small pages.

Comment thread frontend/src/pages/team/Instances.vue Outdated
return {
loading: false,
// Gates the full-page spinner to first paint so refetches don't unmount the search input.
hasLoadedOnce: false,
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.

this is hurting my brain. I'm not going to block it, but we need to understand why we need to resort to these sorts of things and address the underlying issue

Copy link
Copy Markdown
Contributor Author

@n-lark n-lark Jun 3, 2026

Choose a reason for hiding this comment

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

Yah issue was the table would remount so it felt glitchy flashy. So I went down the path of a skeleton loader first but ended up just gating the load on loading && !instancesMap.size so we don't flashy glitch on page change.

pendingStateChange: 'pendingStateChange',
optimisticStateChange: 'optimisticStateChange',
status: 'meta.state'
status: 'status'
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.

I may be wrong, but status and meta.status are not the same

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.

I think i'm getting what you're doing. you're now pre-loading every instance status by default, so we won't need to re-query all instances iteratively/in a cascade. I can't place exactly where you're doing that just yet

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.

nvm, found it, you're including includeMeta: true in the initial fetch data request

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 problem with that is that the byTeam method returns the db entries of the statuses (which do not include the transitory states instances can be in, only the stable ones - see https://flowfuse.com/docs/user/instance-states/#flowfuse-node-red-instance-states). the old getInstanceMeta was calling '/:instanceId/status' which in turn was getting the actual live state of the instance by calling await instance.liveState({ omitStorageFlows: true }) which retrieved it's data twofold, corroborating from the db and from the cache. the byTeam endpoint is not doing that

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 result is that the db instance status can be running (which it well may be), but its actual state could be (installing,pending etc)

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.

This little bit needs a rethink. My thinking is that the best way about it would be to make the byTeam method use the project's actual liveState. I'm sure there were reasons why this was not done this way in the first place, but now maybe that we have proper caching in place for statuses, we might get away with it.

@hardillb I'd appreciate your input on the above proposition

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

For the liveState thing, isn't that what this does in Project.js? It runs through the same liveState method in the view layer the old endpointed used.

if (includeMeta) {
    const liveState = await project.liveState()
    result.meta = liveState?.meta
    result.flowLastUpdatedAt = liveState?.flowLastUpdatedAt
}

So its like byTeam -> Project.js liveState in view -> we get back the correct pending, installing, starting state etc.

I also tested this for starting  in the UI and it returns the pending state so it is working.

Screenshot 2026-06-03 at 8 20 06 AM

I may be wrong, but status and meta.status are not the same

They hold the same value on hosted-instance rows, we normalize meta.state onto r.status in api/team.js:223-224. The reason we map the column to status instead of meta.state is that the dashboard-role endpoint (/dashboard-instances) returns top-level status and no meta object, so the old meta.state mapping gave dashboard users undefined and a forever spinner. Reading status works for both response shapes.

// Gates the full-page spinner to first paint so refetches don't unmount the search input.
hasLoadedOnce: false,
instancesMap: new Map(),
page: 1,
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.

this is the 2nd time i've seen these params (granted without the default sort) but i'd say we could have a default object that we could instantiate where needed with these props and their defaults

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Done.

Comment thread frontend/src/pages/team/Instances.vue Outdated
paginationProps () {
if (this.dashboardRoleOnly) return null
// Hide only when no page size could ever split the result — keeps the size selector reachable otherwise.
if (this.totalRows <= 10) return null
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.

again, I feel this should be handled within the component and is a leak

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Fixed

@n-lark n-lark requested a review from cstns June 3, 2026 20:42
@n-lark
Copy link
Copy Markdown
Contributor Author

n-lark commented Jun 3, 2026

Hey @cstns all feedback should be addressed - thanks!

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.

Add proper pagination to Hosted and Remote Instances pages + N+1 fix for Hosted Instances

2 participants