Skip to content
Open
Show file tree
Hide file tree
Changes from 26 commits
Commits
Show all changes
40 commits
Select commit Hold shift + click to select a range
7d5c31a
feat(db): add PaginationOptions and PaginatedResult types
fabiovincenzi Mar 20, 2026
8541fb5
feat(db): add buildSearchFilter, buildSort and paginatedFind helpers
fabiovincenzi Mar 20, 2026
2bb4bfa
feat(db/file): implement paginated getRepos, getUsers, getPushes
fabiovincenzi Mar 20, 2026
74895fd
feat(db/mongo): implement paginated getRepos, getUsers, getPushes
fabiovincenzi Mar 20, 2026
bf9863c
refactor(db): update internal callers to destructure paginated results
fabiovincenzi Mar 20, 2026
2188282
feat(api): add parsePaginationParams and toPublicUser route utilities
fabiovincenzi Mar 20, 2026
a0a8b5d
feat(api): add pagination support to push route
fabiovincenzi Mar 20, 2026
af7d290
feat(api): add pagination support to repo route
fabiovincenzi Mar 20, 2026
16fac86
feat(api): add pagination support to users route
fabiovincenzi Mar 20, 2026
c76e4a1
refactor(proxy): update proxy and checkUserPushPermission for paginat…
fabiovincenzi Mar 20, 2026
85e3666
feat(ui): update git-push, repo, user services for paged responses
fabiovincenzi Mar 20, 2026
c5096ba
feat(ui): add Pagination component
fabiovincenzi Mar 20, 2026
d079512
refactor(ui): migrate Filtering component to Material UI Select
fabiovincenzi Mar 20, 2026
7ab5852
feat(ui): wire pagination into PushesTable, UserList and Repositories…
fabiovincenzi Mar 20, 2026
3a3891f
fix(cli): update getGitPushes to handle paginated API response
fabiovincenzi Mar 20, 2026
56922f9
test: update existing tests for paginated responses
fabiovincenzi Mar 20, 2026
7716b4d
test: add unit tests for buildSearchFilter, buildSort and parsePagina…
fabiovincenzi Mar 20, 2026
965efa0
test: update mongo integration tests for paginated responses
fabiovincenzi Mar 20, 2026
4c95924
test: add unit tests for paginatedFind in mongo helper
fabiovincenzi Mar 20, 2026
e39c460
test: update e2e push test for paginated getRepos response
fabiovincenzi Mar 20, 2026
ab824d6
test: update Cypress commands for paginated repo API response
fabiovincenzi Mar 20, 2026
925c6bc
Merge branch 'main' into server-side-pagination
kriswest Mar 23, 2026
e7fc82f
Merge branch 'main' into server-side-pagination
fabiovincenzi Mar 24, 2026
367226f
fix: validate sortBy against per-endpoint allowlist to prevent sortin…
fabiovincenzi Mar 24, 2026
8b545ab
fix: guard against missing or non-string username body param in repo …
fabiovincenzi Mar 24, 2026
a68cb70
fix: load up to 100 users in AddUser dialog with integrated search in…
fabiovincenzi Mar 24, 2026
a611fb9
fix: skip countDocuments for unpaginated internal queries
fabiovincenzi Mar 24, 2026
c5ecc98
fix: debounce search input
fabiovincenzi Mar 24, 2026
2c222a9
fix: cap skip at 10000 to prevent deep pagination queries
fabiovincenzi Mar 24, 2026
a09117d
fix: rename paginated API response fields to entity-specific keys
fabiovincenzi Mar 25, 2026
eaaf5de
fix: keep Search mounted during loading to prevent pagination reset
fabiovincenzi Mar 25, 2026
876b185
refactor: introduce PaginationQuery type to avoid repeated casts in p…
fabiovincenzi Mar 26, 2026
259b51a
test: update tests to reflect renamed paginated response fields
fabiovincenzi Mar 26, 2026
6beb5f1
fix: remove Date Modified and Date Created from sort dropdown
fabiovincenzi Mar 26, 2026
212acdf
test: add cypress tests for search and pagination in repo list
fabiovincenzi Mar 26, 2026
846d33e
test: fix search cypress test to wait for initial load before interce…
fabiovincenzi Mar 26, 2026
06b6af7
Merge branch 'main' into server-side-pagination
fabiovincenzi Mar 27, 2026
8dc82dc
test: fix e2e test to use renamed repos field in getRepos response
fabiovincenzi Mar 27, 2026
e737d6e
Merge branch 'server-side-pagination' of https://github.com/fabiovinc…
fabiovincenzi Mar 27, 2026
6c7d262
Merge branch 'main' into server-side-pagination
fabiovincenzi Apr 3, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 8 additions & 8 deletions cypress/support/commands.js
Original file line number Diff line number Diff line change
Expand Up @@ -134,18 +134,17 @@ Cypress.Commands.add('getTestRepoId', () => {
`GET ${url} returned status ${res.status}: ${JSON.stringify(res.body).slice(0, 500)}`,
);
}
if (!Array.isArray(res.body)) {
const repos = res.body.data ?? res.body;
if (!Array.isArray(repos)) {
throw new Error(
`GET ${url} returned non-array (${typeof res.body}): ${JSON.stringify(res.body).slice(0, 500)}`,
`GET ${url} returned unexpected shape: ${JSON.stringify(res.body).slice(0, 500)}`,
);
}
const gitServerTarget = Cypress.env('GIT_SERVER_TARGET') || 'git-server:8443';
const repo = res.body.find(
(r) => r.url === `https://${gitServerTarget}/test-owner/test-repo.git`,
);
const repo = repos.find((r) => r.url === `https://${gitServerTarget}/test-owner/test-repo.git`);
if (!repo) {
throw new Error(
`test-owner/test-repo not found in database. Repos: ${res.body.map((r) => r.url).join(', ')}`,
`test-owner/test-repo not found in database. Repos: ${repos.map((r) => r.url).join(', ')}`,
);
}
return cy.wrap(repo._id);
Expand All @@ -159,8 +158,9 @@ Cypress.Commands.add('cleanupTestRepos', () => {
url: `${getApiBaseUrl()}/api/v1/repo`,
failOnStatusCode: false,
}).then((res) => {
if (res.status !== 200 || !Array.isArray(res.body)) return;
const testRepos = res.body.filter((r) => r.project === 'cypress-test');
const repos = res.body.data ?? res.body;
if (res.status !== 200 || !Array.isArray(repos)) return;
const testRepos = repos.filter((r) => r.project === 'cypress-test');
testRepos.forEach((repo) => {
cy.request({
method: 'DELETE',
Expand Down
4 changes: 2 additions & 2 deletions packages/git-proxy-cli/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -106,12 +106,12 @@ async function getGitPushes(filters: Partial<PushQuery>) {

try {
const cookies = JSON.parse(fs.readFileSync(GIT_PROXY_COOKIE_FILE, 'utf8'));
const { data } = await axios.get<Action[]>(`${baseUrl}/api/v1/push/`, {
const response = await axios.get<{ data: Action[]; total: number }>(`${baseUrl}/api/v1/push/`, {
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.

Can use PaginatedResult type here

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.

the /push endpoint now returns pushes instead of data.

headers: { Cookie: cookies },
params: filters,
});

const records = data.map((push: Action) => {
const records = response.data.data.map((push: Action) => {
const {
id,
repo,
Expand Down
32 changes: 32 additions & 0 deletions src/db/file/helper.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,8 +15,40 @@
*/

import { existsSync, mkdirSync } from 'fs';
import Datastore from '@seald-io/nedb';
import { PaginatedResult } from '../types';

export const getSessionStore = (): undefined => undefined;
export const initializeFolders = () => {
if (!existsSync('./.data/db')) mkdirSync('./.data/db', { recursive: true });
};

export const paginatedFind = <T>(
db: Datastore,
filter: Record<string, unknown>,
sort: Record<string, 1 | -1>,
skip: number,
limit: number,
): Promise<PaginatedResult<T>> => {
const countPromise = new Promise<number>((resolve, reject) => {
db.count(filter as any, (err: Error | null, count: number) => {
/* istanbul ignore if */
if (err) reject(err);
else resolve(count);
});
});

const dataPromise = new Promise<T[]>((resolve, reject) => {
db.find(filter as any)
.sort(sort)
.skip(skip)
.limit(limit)
.exec((err: Error | null, docs: any[]) => {
/* istanbul ignore if */
if (err) reject(err);
else resolve(docs);
});
});

return Promise.all([dataPromise, countPromise]).then(([data, total]) => ({ data, total }));
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'm wondering if we should change the data variable to something more descriptive... Right now, we end up calling response.data.data to fetch the repos/users/etc. which can trip up people/agents working with the code.

I'd suggest having the name of the entity { users, total }, etc. although we'd need to pass in the entity name to paginatedFind (unless it's already obtainable in the existing function parameters).

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.

Good point, the HTTP routes now return entity-specific keys (pushes, repos, users) instead of data. The data variable inside paginatedFind remains as a generic internal name but not exposed directly.

};
51 changes: 30 additions & 21 deletions src/db/file/pushes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,8 +17,9 @@
import _ from 'lodash';
import Datastore from '@seald-io/nedb';
import { Action } from '../../proxy/actions/Action';
import { toClass } from '../helper';
import { PushQuery } from '../types';
import { toClass, buildSearchFilter, buildSort } from '../helper';
import { paginatedFind } from './helper';
import { PaginatedResult, PaginationOptions, PushQuery } from '../types';
import { CompletedAttestation, Rejection } from '../../proxy/processors/types';
import { handleErrorAndLog } from '../../utils/errors';

Expand Down Expand Up @@ -49,25 +50,33 @@ const defaultPushQuery: Partial<PushQuery> = {
type: 'push',
};

export const getPushes = (query: Partial<PushQuery>): Promise<Action[]> => {
export const getPushes = (
query: Partial<PushQuery>,
pagination?: PaginationOptions,
): Promise<PaginatedResult<Action>> => {
if (!query) query = defaultPushQuery;
return new Promise((resolve, reject) => {
db.find(query)
.sort({ timestamp: -1 })
.exec((err, docs) => {
// ignore for code coverage as neDB rarely returns errors even for an invalid query
/* istanbul ignore if */
if (err) {
reject(err);
} else {
resolve(
_.chain(docs)
.map((x) => toClass(x, Action.prototype))
.value(),
);
}
});
});

const baseQuery = buildSearchFilter(
{ ...query },
['repo', 'branch', 'commitTo', 'user'],
pagination?.search,
);
const sort = buildSort(pagination, 'timestamp', -1, [
'timestamp',
'repo',
'branch',
'commitTo',
'user',
]);
const skip = pagination?.skip ?? 0;
const limit = pagination?.limit ?? 0;

return paginatedFind<Action>(db, baseQuery, sort, skip, limit).then(({ data, total }) => ({
data: _.chain(data)
.map((x) => toClass(x, Action.prototype))
.value(),
total,
}));
};

export const getPush = async (id: string): Promise<Action | null> => {
Expand Down Expand Up @@ -157,5 +166,5 @@ export const cancel = async (id: string): Promise<{ message: string }> => {
action.canceled = true;
action.rejected = false;
await writeAudit(action);
return { message: `cancel ${id}` };
return { message: `canceled ${id}` };
};
37 changes: 19 additions & 18 deletions src/db/file/repo.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,8 +17,9 @@
import Datastore from '@seald-io/nedb';
import _ from 'lodash';

import { Repo, RepoQuery } from '../types';
import { toClass } from '../helper';
import { PaginatedResult, PaginationOptions, Repo, RepoQuery } from '../types';
import { toClass, buildSearchFilter, buildSort } from '../helper';
import { paginatedFind } from './helper';
import { handleErrorAndLog } from '../../utils/errors';

const COMPACTION_INTERVAL = 1000 * 60 * 60 * 24; // once per day
Expand All @@ -42,25 +43,25 @@ try {
db.ensureIndex({ fieldName: 'name', unique: false });
db.setAutocompactionInterval(COMPACTION_INTERVAL);

export const getRepos = async (query: Partial<RepoQuery> = {}): Promise<Repo[]> => {
export const getRepos = async (
query: Partial<RepoQuery> = {},
pagination?: PaginationOptions,
): Promise<PaginatedResult<Repo>> => {
if (query?.name) {
query.name = query.name.toLowerCase();
}
return new Promise<Repo[]>((resolve, reject) => {
db.find(query, (err: Error, docs: Repo[]) => {
// ignore for code coverage as neDB rarely returns errors even for an invalid query
/* istanbul ignore if */
if (err) {
reject(err);
} else {
resolve(
_.chain(docs)
.map((x) => toClass(x, Repo.prototype))
.value(),
);
}
});
});

const baseQuery = buildSearchFilter({ ...query }, ['name', 'project', 'url'], pagination?.search);
const sort = buildSort(pagination, 'name', 1, ['name', 'project', 'url']);
const skip = pagination?.skip ?? 0;
const limit = pagination?.limit ?? 0;

return paginatedFind<Repo>(db, baseQuery, sort, skip, limit).then(({ data, total }) => ({
data: _.chain(data)
.map((x) => toClass(x, Repo.prototype))
.value(),
total,
}));
};

export const getRepo = async (name: string): Promise<Repo | null> => {
Expand Down
36 changes: 23 additions & 13 deletions src/db/file/users.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,8 +17,10 @@
import fs from 'fs';
import Datastore from '@seald-io/nedb';

import { User, UserQuery } from '../types';
import { PaginatedResult, PaginationOptions, User, UserQuery } from '../types';
import { handleErrorAndLog } from '../../utils/errors';
import { buildSearchFilter, buildSort } from '../helper';
import { paginatedFind } from './helper';

const COMPACTION_INTERVAL = 1000 * 60 * 60 * 24; // once per day

Expand Down Expand Up @@ -180,22 +182,30 @@ export const updateUser = (user: Partial<User>): Promise<void> => {
});
};

export const getUsers = (query: Partial<UserQuery> = {}): Promise<User[]> => {
export const getUsers = (
query: Partial<UserQuery> = {},
pagination?: PaginationOptions,
): Promise<PaginatedResult<User>> => {
if (query.username) {
query.username = query.username.toLowerCase();
}
if (query.email) {
query.email = query.email.toLowerCase();
}
return new Promise<User[]>((resolve, reject) => {
db.find(query, (err: Error, docs: User[]) => {
// ignore for code coverage as neDB rarely returns errors even for an invalid query
/* istanbul ignore if */
if (err) {
reject(err);
} else {
resolve(docs);
}
});
});

const baseQuery = buildSearchFilter(
{ ...query },
['username', 'displayName', 'email', 'gitAccount'],
pagination?.search,
);
const sort = buildSort(pagination, 'username', 1, [
'username',
'displayName',
'email',
'gitAccount',
]);
const skip = pagination?.skip ?? 0;
const limit = pagination?.limit ?? 0;

return paginatedFind<User>(db, baseQuery, sort, skip, limit);
};
30 changes: 30 additions & 0 deletions src/db/helper.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,36 @@
* limitations under the License.
*/

import { PaginationOptions } from './types';

export const escapeRegex = (s: string): string => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');

export const buildSearchFilter = (
baseQuery: Record<string, unknown>,
searchFields: string[],
search?: string,
): Record<string, unknown> => {
if (!search) return baseQuery;
const regex = new RegExp(escapeRegex(search), 'i');
return { ...baseQuery, $or: searchFields.map((f) => ({ [f]: regex })) };
};

export const buildSort = (
pagination: PaginationOptions | undefined,
defaultField: string,
defaultDir: 1 | -1,
allowedFields?: string[],
): Record<string, 1 | -1> => {
const requestedField = pagination?.sortBy;
const field =
requestedField && (!allowedFields || allowedFields.includes(requestedField))
? requestedField
: defaultField;
const dir =
pagination?.sortOrder === 'asc' ? 1 : pagination?.sortOrder === 'desc' ? -1 : defaultDir;
return { [field]: dir };
};

export const toClass = function <T, U>(obj: T, proto: U): U {
const out = JSON.parse(JSON.stringify(obj));
out.__proto__ = proto;
Expand Down
30 changes: 24 additions & 6 deletions src/db/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,16 @@
*/

import { AuthorisedRepo } from '../config/generated/config';
import { PushQuery, Repo, RepoQuery, Sink, User, UserQuery } from './types';
import {
PaginatedResult,
PaginationOptions,
PushQuery,
Repo,
RepoQuery,
Sink,
User,
UserQuery,
} from './types';
import * as bcrypt from 'bcryptjs';
import * as config from '../config';
import * as mongo from './mongo';
Expand Down Expand Up @@ -177,7 +186,10 @@ export const canUserCancelPush = async (id: string, user: string) => {
};

export const getSessionStore = (): MongoDBStore | undefined => start().getSessionStore();
export const getPushes = (query: Partial<PushQuery>): Promise<Action[]> => start().getPushes(query);
export const getPushes = (
query: Partial<PushQuery>,
pagination?: PaginationOptions,
): Promise<PaginatedResult<Action>> => start().getPushes(query, pagination);
export const writeAudit = (action: Action): Promise<void> => start().writeAudit(action);
export const getPush = (id: string): Promise<Action | null> => start().getPush(id);
export const deletePush = (id: string): Promise<void> => start().deletePush(id);
Expand All @@ -188,7 +200,10 @@ export const authorise = (
export const cancel = (id: string): Promise<{ message: string }> => start().cancel(id);
export const reject = (id: string, rejection: Rejection): Promise<{ message: string }> =>
start().reject(id, rejection);
export const getRepos = (query?: Partial<RepoQuery>): Promise<Repo[]> => start().getRepos(query);
export const getRepos = (
query?: Partial<RepoQuery>,
pagination?: PaginationOptions,
): Promise<PaginatedResult<Repo>> => start().getRepos(query, pagination);
export const getRepo = (name: string): Promise<Repo | null> => start().getRepo(name);
export const getRepoByUrl = (url: string): Promise<Repo | null> => start().getRepoByUrl(url);
export const getRepoById = (_id: string): Promise<Repo | null> => start().getRepoById(_id);
Expand All @@ -206,7 +221,10 @@ export const findUserByEmail = (email: string): Promise<User | null> =>
start().findUserByEmail(email);
export const findUserByOIDC = (oidcId: string): Promise<User | null> =>
start().findUserByOIDC(oidcId);
export const getUsers = (query?: Partial<UserQuery>): Promise<User[]> => start().getUsers(query);
export const getUsers = (
query?: Partial<UserQuery>,
pagination?: PaginationOptions,
): Promise<PaginatedResult<User>> => start().getUsers(query, pagination);
export const deleteUser = (username: string): Promise<void> => start().deleteUser(username);

export const updateUser = (user: Partial<User>): Promise<void> => start().updateUser(user);
Expand All @@ -218,7 +236,7 @@ export const updateUser = (user: Partial<User>): Promise<void> => start().update
*/

export const getAllProxiedHosts = async (): Promise<string[]> => {
const repos = await getRepos();
const { data: repos } = await getRepos();
const origins = new Set<string>();
repos.forEach((repo) => {
const parsedUrl = processGitUrl(repo.url);
Expand All @@ -229,4 +247,4 @@ export const getAllProxiedHosts = async (): Promise<string[]> => {
return Array.from(origins);
};

export type { PushQuery, Repo, Sink, User } from './types';
export type { PaginatedResult, PaginationOptions, PushQuery, Repo, Sink, User } from './types';
Loading
Loading