From 11f41dfabf276e5ee7c6800d6dca16496b02721d Mon Sep 17 00:00:00 2001
From: Martin Varga
Date: Fri, 1 May 2026 14:03:39 +0200
Subject: [PATCH 01/16] Improve DB query logic in 3 endpoints which were
causing performance issues
GET /v1/resource/history
GET /v1/project/
POST /v1/project/by_names
were causing N+1 query issues
---
server/mergin/sync/interfaces.py | 6 +
server/mergin/sync/models.py | 34 +++-
server/mergin/sync/public_api.yaml | 5 -
server/mergin/sync/public_api_controller.py | 181 ++++++++++++++----
server/mergin/sync/workspace.py | 8 +
.../mergin/tests/test_project_controller.py | 1 -
6 files changed, 187 insertions(+), 48 deletions(-)
diff --git a/server/mergin/sync/interfaces.py b/server/mergin/sync/interfaces.py
index bb2e9843..4f30c2bc 100644
--- a/server/mergin/sync/interfaces.py
+++ b/server/mergin/sync/interfaces.py
@@ -110,6 +110,12 @@ def get_by_name(self, name):
"""
pass
+ def get_by_names(self, names):
+ """
+ Return list of workspaces whose names are in the given collection.
+ """
+ pass
+
@abstractmethod
def get_by_project(self, project):
"""
diff --git a/server/mergin/sync/models.py b/server/mergin/sync/models.py
index 5f4aa967..ca1ab926 100644
--- a/server/mergin/sync/models.py
+++ b/server/mergin/sync/models.py
@@ -18,7 +18,9 @@
from blinker import signal
from flask_login import current_user
from pygeodiff import GeoDiff
+from functools import cached_property
from sqlalchemy import text, null, desc, nullslast, tuple_
+from sqlalchemy.orm import contains_eager, joinedload, load_only
from sqlalchemy.dialects.postgresql import ARRAY, BIGINT, UUID, JSONB, ENUM, insert
from sqlalchemy.types import String
from sqlalchemy.ext.hybrid import hybrid_property
@@ -658,7 +660,7 @@ def __init__(
def path(self) -> str:
return self.file.path
- @property
+ @cached_property
def diff(self) -> Optional[FileDiff]:
"""Diff file pushed with UPDATE_DIFF change type.
@@ -713,9 +715,36 @@ def changes(
if not (is_versioned_file(file) and since is not None and to is not None):
return []
- history = []
+ # when since=1 the range spans the entire project history; narrow it to
+ # the most recent CREATE/DELETE so we don't load records from previous
+ # file lifecycles that the Python break would discard anyway
+ if since == 1:
+ boundary = (
+ FileHistory.query.join(ProjectFilePath)
+ .filter(
+ ProjectFilePath.project_id == project_id,
+ ProjectFilePath.path == file,
+ FileHistory.project_version_name <= to,
+ FileHistory.change.in_(
+ [PushChangeType.CREATE.value, PushChangeType.DELETE.value]
+ ),
+ )
+ .order_by(desc(FileHistory.project_version_name))
+ .with_entities(FileHistory.project_version_name)
+ .first()
+ )
+ since = boundary[0] if boundary else since
+
full_history = (
FileHistory.query.join(ProjectFilePath)
+ .join(FileHistory.version)
+ .join(ProjectVersion.project)
+ .options(
+ contains_eager(FileHistory.version)
+ .load_only(ProjectVersion.name, ProjectVersion.project_id)
+ .contains_eager(ProjectVersion.project)
+ .load_only(Project.storage_params)
+ )
.filter(
ProjectFilePath.project_id == project_id,
FileHistory.project_version_name <= to,
@@ -726,6 +755,7 @@ def changes(
.all()
)
+ history = []
for item in full_history:
history.append(item)
diff --git a/server/mergin/sync/public_api.yaml b/server/mergin/sync/public_api.yaml
index 157e8262..7f5749d1 100644
--- a/server/mergin/sync/public_api.yaml
+++ b/server/mergin/sync/public_api.yaml
@@ -1124,11 +1124,6 @@ components:
- added
- updated
- removed
- expiration:
- nullable: true
- type: string
- format: date-time
- example: 2019-02-26T08:47:58.636074Z
UploadFileInfo:
allOf:
- $ref: "#/components/schemas/FileInfo"
diff --git a/server/mergin/sync/public_api_controller.py b/server/mergin/sync/public_api_controller.py
index 8f142e71..a3457e0a 100644
--- a/server/mergin/sync/public_api_controller.py
+++ b/server/mergin/sync/public_api_controller.py
@@ -24,8 +24,9 @@
)
from pygeodiff import GeoDiffLibError
from flask_login import current_user
-from sqlalchemy import and_, desc, asc
+from sqlalchemy import and_, desc, asc, tuple_
from sqlalchemy.exc import IntegrityError, SQLAlchemyError
+from sqlalchemy.orm import contains_eager, joinedload, load_only
from gevent import sleep
import base64
from werkzeug.exceptions import HTTPException, Conflict
@@ -37,6 +38,7 @@
from ..auth.models import User
from .models import (
FileSyncErrorType,
+ FileDiff,
Project,
ProjectVersion,
Upload,
@@ -397,19 +399,73 @@ def get_project(project_name, namespace, since="", version=None): # noqa: E501
abort(400, "Parameters 'since' and 'version' are mutually exclusive")
elif since:
data = ProjectSchema(exclude=["storage_params"]).dump(project)
- # append history for versioned files
+ since_version = ProjectVersion.from_v_name(since)
+ versioned_paths = [f.path for f in project.files if is_versioned_file(f.path)]
+
+ # load history for all versioned files in one query; only the columns
+ # actually used downstream are fetched from the joined tables
+ all_history = (
+ FileHistory.query.join(ProjectFilePath)
+ .join(FileHistory.version)
+ .options(
+ contains_eager(FileHistory.file).load_only(
+ ProjectFilePath.path, ProjectFilePath.project_id
+ ),
+ contains_eager(FileHistory.version).load_only(ProjectVersion.name),
+ )
+ .filter(
+ ProjectFilePath.project_id == project.id,
+ FileHistory.project_version_name.between(
+ since_version, project.latest_version
+ ),
+ ProjectFilePath.path.in_(versioned_paths),
+ )
+ .order_by(FileHistory.file_path_id, desc(FileHistory.project_version_name))
+ .all()
+ )
+
+ # partition by file and apply stop-at-CREATE logic, matching FileHistory.changes behaviour
+ history_by_file: dict = {}
+ for item in all_history:
+ fid = item.file_path_id
+ file_history = history_by_file.setdefault(fid, [])
+ if file_history and file_history[-1].change in (
+ PushChangeType.CREATE.value,
+ PushChangeType.DELETE.value,
+ ):
+ continue
+ file_history.append(item)
+
+ # batch-load all FileDiff records needed across all files in one query
+ update_diff_items = [
+ i
+ for items in history_by_file.values()
+ for i in items
+ if i.change == PushChangeType.UPDATE_DIFF.value
+ ]
+ if update_diff_items:
+ diffs = FileDiff.query.filter(
+ FileDiff.file_path_id.in_({i.file_path_id for i in update_diff_items}),
+ FileDiff.rank == 0,
+ FileDiff.version.in_(
+ [i.project_version_name for i in update_diff_items]
+ ),
+ ).all()
+ diff_map = {(d.file_path_id, d.version): d for d in diffs}
+ for item in update_diff_items:
+ item.__dict__["diff"] = diff_map.get(
+ (item.file_path_id, item.project_version_name)
+ )
+
+ path_to_file_id = {i.file.path: i.file_path_id for i in all_history}
files = []
for f in project.files:
history_field = {}
- for item in FileHistory.changes(
- project.id,
- f.path,
- ProjectVersion.from_v_name(since),
- project.latest_version,
- ):
- history_field[ProjectVersion.to_v_name(item.version.name)] = (
- FileHistorySchema(exclude=("mtime",)).dump(item)
- )
+ if is_versioned_file(f.path):
+ for item in history_by_file.get(path_to_file_id.get(f.path), []):
+ history_field[ProjectVersion.to_v_name(item.version.name)] = (
+ FileHistorySchema(exclude=("mtime", "expiration")).dump(item)
+ )
files.append({**asdict(f), "history": history_field})
data["files"] = files
elif version:
@@ -470,38 +526,63 @@ def get_projects_by_names(): # noqa: E501
list_of_projects = request.json.get("projects", [])
if len(list_of_projects) > 50:
abort(400, "Too many projects")
+
+ # batch-resolve workspaces by name (one DB query for DB-backed handlers)
+ unique_ws_names = {
+ key.split("/")[0] for key in list_of_projects if len(key.split("/")) == 2
+ }
+ workspaces_by_name = {
+ ws.name: ws for ws in current_app.ws_handler.get_by_names(unique_ws_names)
+ }
+
results = {}
- for project in list_of_projects:
- projects = projects_query(ProjectPermissions.Read, as_admin=False)
- splitted = project.split("/")
- if len(splitted) != 2:
- results[project] = {"error": 404}
+ valid_projects = [] # list of (key, workspace, project_name)
+ for key in list_of_projects:
+ parts = key.split("/")
+ if len(parts) != 2:
+ results[key] = {"error": 404}
continue
- ws = splitted[0]
- name = splitted[1]
- workspace = current_app.ws_handler.get_by_name(ws)
+ workspace = workspaces_by_name.get(parts[0])
if not workspace:
- results[project] = {"error": 404}
+ results[key] = {"error": 404}
continue
- result = projects.filter(
- Project.workspace_id == workspace.id, Project.name == name
- ).first()
- if result:
- users_map = {
- u.id: u.username
- for u in User.query.select_from(ProjectUser)
- .join(User)
- .filter(ProjectUser.project_id == result.id)
- .all()
- }
- workspaces_map = {workspace.id: workspace.name}
- ctx = {"users_map": users_map, "workspaces_map": workspaces_map}
- results[project] = ProjectListSchema(context=ctx).dump(result)
- else:
- if not current_user or not current_user.is_authenticated:
- results[project] = {"error": 401}
+ valid_projects.append((key, workspace, parts[1]))
+
+ if valid_projects:
+ # batch-fetch all requested projects in one query
+ ws_name_pairs = [(ws.id, name) for _, ws, name in valid_projects]
+ found_projects = (
+ projects_query(ProjectPermissions.Read, as_admin=False)
+ .filter(tuple_(Project.workspace_id, Project.name).in_(ws_name_pairs))
+ .all()
+ )
+ found_map = {(p.workspace_id, p.name): p for p in found_projects}
+
+ # batch-fetch all project members in one query
+ users_map = {
+ u.id: u.username
+ for u in User.query.select_from(ProjectUser)
+ .join(User)
+ .filter(ProjectUser.project_id.in_([p.id for p in found_projects]))
+ .all()
+ }
+ ws_ids = {p.workspace_id for p in found_projects}
+ workspaces_map = {
+ w.id: w.name for w in current_app.ws_handler.get_by_ids(ws_ids)
+ }
+ ctx = {"users_map": users_map, "workspaces_map": workspaces_map}
+
+ for key, workspace, name in valid_projects:
+ result = found_map.get((workspace.id, name))
+ if result:
+ results[key] = ProjectListSchema(context=ctx).dump(result)
else:
- results[project] = {"error": 404}
+ results[key] = (
+ {"error": 401}
+ if not current_user or not current_user.is_authenticated
+ else {"error": 404}
+ )
+
return results, 200
@@ -1191,10 +1272,30 @@ def get_resource_history(project_name, namespace, path): # noqa: E501
)
data = ProjectFileSchema().dump(fh)
+ history = FileHistory.changes(project.id, path, 1, project.latest_version)
+
+ # batch-load all rank-0 FileDiff records needed for the history in one query
+ diff_map = {}
+ if history:
+ update_diff_versions = [
+ i.project_version_name
+ for i in history
+ if i.change == PushChangeType.UPDATE_DIFF.value
+ ]
+ if update_diff_versions:
+ diffs = FileDiff.query.filter(
+ FileDiff.file_path_id == history[0].file_path_id,
+ FileDiff.rank == 0,
+ FileDiff.version.in_(update_diff_versions),
+ ).all()
+ diff_map = {d.version: d for d in diffs}
+
history_field = {}
- for item in FileHistory.changes(project.id, path, 1, project.latest_version):
+ for item in history:
+ if item.change == PushChangeType.UPDATE_DIFF.value:
+ item.__dict__["diff"] = diff_map.get(item.project_version_name)
history_field[ProjectVersion.to_v_name(item.version.name)] = FileHistorySchema(
- exclude=("mtime",)
+ exclude=("mtime", "expiration")
).dump(item)
data["history"] = history_field
diff --git a/server/mergin/sync/workspace.py b/server/mergin/sync/workspace.py
index e7575e46..4c9866c5 100644
--- a/server/mergin/sync/workspace.py
+++ b/server/mergin/sync/workspace.py
@@ -144,6 +144,14 @@ def get_by_name(self, name):
return
return self.factory_method()
+ def get_by_names(self, names):
+ result = []
+ for name in set(names):
+ ws = self.get_by_name(name)
+ if ws:
+ result.append(ws)
+ return result
+
def get_by_project(self, project):
return self.factory_method()
diff --git a/server/mergin/tests/test_project_controller.py b/server/mergin/tests/test_project_controller.py
index 60c36ee2..f8a99188 100644
--- a/server/mergin/tests/test_project_controller.py
+++ b/server/mergin/tests/test_project_controller.py
@@ -166,7 +166,6 @@ def test_file_history(client, diff_project):
assert "v1" not in history
assert "v3" in history
assert "location" not in history["v7"]
- assert "expiration" in history["v7"]
def test_get_paginated_projects(client):
From cda65852ac0113f2d9c04d7790f08bc621e433ef Mon Sep 17 00:00:00 2001
From: xkello
Date: Mon, 4 May 2026 14:57:32 +0000
Subject: [PATCH 02/16] Add admin panel enhancements according to #3283
---
.../admin-lib/src/modules/admin/adminApi.ts | 10 ++-
.../admin/components/AccountsTable.vue | 76 +++++++++++++----
.../admin/components/AdminProjectsTable.vue | 83 ++++++++++++++-----
.../admin-lib/src/modules/admin/store.ts | 22 +++--
4 files changed, 147 insertions(+), 44 deletions(-)
diff --git a/web-app/packages/admin-lib/src/modules/admin/adminApi.ts b/web-app/packages/admin-lib/src/modules/admin/adminApi.ts
index 08114297..5a1ef867 100644
--- a/web-app/packages/admin-lib/src/modules/admin/adminApi.ts
+++ b/web-app/packages/admin-lib/src/modules/admin/adminApi.ts
@@ -28,9 +28,10 @@ export const AdminApi = {
},
async fetchUsers(
- params: PaginatedUsersParams
+ params: PaginatedUsersParams,
+ signal?: AbortSignal
): Promise> {
- return AdminModule.httpService.get(`/app/admin/users`, { params })
+ return AdminModule.httpService.get(`/app/admin/users`, { params, signal })
},
async fetchUserByName(
@@ -73,9 +74,10 @@ export const AdminApi = {
},
async getProjects(
- params: PaginatedAdminProjectsParams
+ params: PaginatedAdminProjectsParams,
+ signal?: AbortSignal
): Promise> {
- return AdminModule.httpService.get('/app/admin/projects', { params })
+ return AdminModule.httpService.get('/app/admin/projects', { params, signal })
},
/**
diff --git a/web-app/packages/admin-lib/src/modules/admin/components/AccountsTable.vue b/web-app/packages/admin-lib/src/modules/admin/components/AccountsTable.vue
index 8f456d47..ad312fc7 100644
--- a/web-app/packages/admin-lib/src/modules/admin/components/AccountsTable.vue
+++ b/web-app/packages/admin-lib/src/modules/admin/components/AccountsTable.vue
@@ -145,27 +145,64 @@ export default defineComponent({
{ field: 'email', header: 'Email', sortable: true },
{ field: 'profile.name', header: 'Full name' },
{ field: 'active', header: 'Active' }
- ] as TableDataHeader[]
+ ] as TableDataHeader[],
+ abortController: null as AbortController | null
}
},
computed: {
...mapState(useAdminStore, ['users', 'loading'])
},
created() {
- this.resetPaging = debounce(this.resetPaging, 1000)
- this.fetchUsers({ params: this.getParams() })
+ // Restore any search/sort/page state from the URL before the first fetch
+ this.initFromQuery()
+ // Delay search-triggered fetches so rapid typing doesn't spam the API
+ this.onSearch = debounce(this.onSearch, 500)
+ this.doFetch()
},
methods: {
...mapActions(useAdminStore, ['fetchUsers']),
...mapActions(useDialogStore, ['show']),
- onSearch() {
- this.resetPaging()
- this.fetchUsers({ params: this.getParams() })
+ // Seed local state from URL query params so the page is shareable / survives navigation
+ initFromQuery() {
+ const q = this.$route.query
+ if (q.q) this.searchByName = String(q.q)
+ if (q.page) this.options.page = Number(q.page)
+ if (q.per_page) this.options.itemsPerPage = Number(q.per_page)
+ if (q.order_by) this.options.sortBy[0] = String(q.order_by)
+ if (q.desc) this.options.sortDesc[0] = q.desc === 'true'
+ },
+
+ // Reflect current search/sort/page state into the URL (defaults are omitted to keep URLs clean)
+ updateQuery() {
+ const query: Record = {}
+ if (this.searchByName) query.q = this.searchByName
+ if (this.options.page > 1) query.page = String(this.options.page)
+ if (this.options.itemsPerPage !== 20)
+ query.per_page = String(this.options.itemsPerPage)
+ if (this.options.sortBy[0] && this.options.sortBy[0] !== 'username')
+ query.order_by = this.options.sortBy[0]
+ if (this.options.sortDesc[0]) query.desc = 'true'
+ // replace (not push) so back-button skips intermediate search states
+ this.$router.replace({ query })
+ },
+
+ // Single entry point for all fetches: cancels any in-flight request, syncs the URL, then fetches
+ doFetch() {
+ // Abort the previous request so a stale slower response can't overwrite a newer one
+ this.abortController?.abort()
+ this.abortController = new AbortController()
+ this.updateQuery()
+ this.fetchUsers({
+ params: this.getParams(),
+ signal: this.abortController.signal
+ })
},
- async resetPaging() {
+ // Called on every keystroke (debounced); resets to page 1 so results start from the beginning
+ onSearch() {
this.options.page = 1
+ this.doFetch()
},
getParams(): PaginatedUsersParams {
@@ -184,34 +221,45 @@ export default defineComponent({
},
onRefresh() {
- this.fetchUsers({ params: this.getParams() })
+ this.doFetch()
},
onPage(event: DataTablePageEvent) {
this.options.page = event.page + 1
this.options.itemsPerPage = event.rows
- this.fetchUsers({ params: this.getParams() })
+ this.doFetch()
},
onSort(event: DataTableSortEvent) {
this.options.sortBy[0] = event.sortField?.toString()
this.options.sortDesc[0] = event.sortOrder < 1
- this.fetchUsers({ params: this.getParams() })
+ this.doFetch()
},
rowClick(event: DataTableRowClickEvent) {
- this.$router.push({
+ const originalEvent = event.originalEvent as MouseEvent
+ // Let the browser handle clicks that originate from a link inside the row (e.g. username column)
+ if ((originalEvent.target as HTMLElement).closest('a')) return
+
+ const location = {
name: AdminRoutes.ACCOUNT,
params: { username: event.data.username }
- })
+ }
+ // Ctrl/Cmd/Shift+click opens in a new tab; plain click navigates in the same tab
+ if (originalEvent.ctrlKey || originalEvent.metaKey || originalEvent.shiftKey) {
+ window.open(this.$router.resolve(location).href, '_blank')
+ } else {
+ this.$router.push(location)
+ }
},
createUserDialog() {
const dialog = { maxWidth: 500, header: 'Create user' }
const listeners = {
success: () => {
- this.resetPaging()
- this.fetchUsers({ params: this.getParams() })
+ // After creating a user, go back to page 1 so the new account is visible
+ this.options.page = 1
+ this.doFetch()
}
}
this.show({
diff --git a/web-app/packages/admin-lib/src/modules/admin/components/AdminProjectsTable.vue b/web-app/packages/admin-lib/src/modules/admin/components/AdminProjectsTable.vue
index 8ac2ea4f..7af70914 100644
--- a/web-app/packages/admin-lib/src/modules/admin/components/AdminProjectsTable.vue
+++ b/web-app/packages/admin-lib/src/modules/admin/components/AdminProjectsTable.vue
@@ -189,6 +189,7 @@ import {
AppContainer,
ConfirmDialogProps
} from '@mergin/lib'
+import debounce from 'lodash/debounce'
import { mapActions, mapState } from 'pinia'
import {
DataTablePageEvent,
@@ -221,7 +222,8 @@ export default defineComponent({
data() {
return {
options: Object.assign({}, this.initialOptions),
- search: ''
+ search: '',
+ abortController: null as AbortController | null
}
},
computed: {
@@ -247,8 +249,11 @@ export default defineComponent({
}
},
created() {
- this.resetPaging()
- this.fetchProjects()
+ // Restore any search/sort/page state from the URL before the first fetch
+ this.initFromQuery()
+ // Delay search-triggered fetches so rapid typing doesn't spam the API
+ this.onSearch = debounce(this.onSearch, 500)
+ this.doFetch()
},
methods: {
...mapActions(useDialogStore, { showDialog: 'show' }),
@@ -259,46 +264,86 @@ export default defineComponent({
'deleteProject'
]),
- paginating(options) {
- this.options = options
- this.fetchProjects()
+ // Seed local state from URL query params so the page is shareable / survives navigation
+ initFromQuery() {
+ const q = this.$route.query
+ if (q.q) this.search = String(q.q)
+ if (q.page) this.options.page = Number(q.page)
+ if (q.per_page) this.options.itemsPerPage = Number(q.per_page)
+ if (q.order_by) this.options.sortBy[0] = String(q.order_by)
+ if (q.desc) this.options.sortDesc[0] = q.desc === 'true'
},
- async resetPaging() {
- this.options.page = 1
+ // Reflect current search/sort/page state into the URL (defaults are omitted to keep URLs clean)
+ updateQuery() {
+ const query: Record = {}
+ if (this.search) query.q = this.search
+ if (this.options.page > 1) query.page = String(this.options.page)
+ if (this.options.itemsPerPage !== 20)
+ query.per_page = String(this.options.itemsPerPage)
+ if (this.options.sortBy[0] && this.options.sortBy[0] !== 'updated')
+ query.order_by = this.options.sortBy[0]
+ if (!this.options.sortDesc[0]) query.desc = 'false'
+ // replace (not push) so back-button skips intermediate search states
+ this.$router.replace({ query })
},
- fetchProjects() {
- this.getProjects({ params: { ...this.options, like: this.search } })
+ // Single entry point for all fetches: cancels any in-flight request, syncs the URL, then fetches
+ doFetch() {
+ // Abort the previous request so a stale slower response can't overwrite a newer one
+ this.abortController?.abort()
+ this.abortController = new AbortController()
+ this.updateQuery()
+ this.getProjects({
+ params: { ...this.options, like: this.search },
+ signal: this.abortController.signal
+ })
},
+ paginating(options) {
+ this.options = options
+ this.doFetch()
+ },
+
+ // Called on every keystroke (debounced); resets to page 1 so results start from the beginning
onSearch() {
- this.resetPaging()
- this.fetchProjects()
+ this.options.page = 1
+ this.doFetch()
},
onPage(event: DataTablePageEvent) {
this.options.page = event.page + 1
this.options.itemsPerPage = event.rows
- this.fetchProjects()
+ this.doFetch()
},
onSort(event: DataTableSortEvent) {
this.options.sortBy[0] = event.sortField?.toString()
this.options.sortDesc[0] = event.sortOrder < 1
- this.fetchProjects()
+ this.doFetch()
},
rowClick(event: DataTableRowClickEvent) {
+ // Removed projects have no detail view, only Restore/Delete buttons
if (event.data.removed_at) return
- this.$router.push({
+ const originalEvent = event.originalEvent as MouseEvent
+ // Let the browser handle clicks that originate from a link or button inside the row
+ if ((originalEvent.target as HTMLElement).closest('a, button')) return
+
+ const location = {
name: AdminRoutes.PROJECT,
params: {
namespace: event.data.workspace,
projectName: event.data.name
}
- })
+ }
+ // Ctrl/Cmd/Shift+click opens in a new tab; plain click navigates in the same tab
+ if (originalEvent.ctrlKey || originalEvent.metaKey || originalEvent.shiftKey) {
+ window.open(this.$router.resolve(location).href, '_blank')
+ } else {
+ this.$router.push(location)
+ }
},
confirmRestore(item) {
@@ -309,7 +354,7 @@ export default defineComponent({
const listeners = {
confirm: async () => {
await this.restoreProject({ projectId: item.id })
- this.fetchProjects()
+ this.doFetch()
}
}
this.showDialog({
@@ -334,7 +379,7 @@ export default defineComponent({
const listeners = {
confirm: async () => {
await this.deleteProject({ projectId: item.id })
- this.fetchProjects()
+ this.doFetch()
}
}
this.showDialog({
@@ -344,7 +389,7 @@ export default defineComponent({
},
onRefresh() {
- this.fetchProjects()
+ this.doFetch()
}
}
})
diff --git a/web-app/packages/admin-lib/src/modules/admin/store.ts b/web-app/packages/admin-lib/src/modules/admin/store.ts
index a0544bbd..5bf5c5fa 100644
--- a/web-app/packages/admin-lib/src/modules/admin/store.ts
+++ b/web-app/packages/admin-lib/src/modules/admin/store.ts
@@ -118,15 +118,20 @@ export const useAdminStore = defineStore('adminModule', {
this.isServerConfigHidden = value
},
- async fetchUsers(payload: { params: PaginatedUsersParams }) {
+ async fetchUsers(payload: {
+ params: PaginatedUsersParams
+ signal?: AbortSignal
+ }) {
const notificationStore = useNotificationStore()
this.setLoading(true)
try {
- const response = await AdminApi.fetchUsers(payload.params)
+ const response = await AdminApi.fetchUsers(payload.params, payload.signal)
this.setUsers(response.data)
} catch (e) {
- notificationStore.error({ text: errorUtils.getErrorMessage(e) })
+ if (!axios.isCancel(e)) {
+ notificationStore.error({ text: errorUtils.getErrorMessage(e) })
+ }
} finally {
this.setLoading(false)
}
@@ -261,6 +266,7 @@ export const useAdminStore = defineStore('adminModule', {
async getProjects(payload: {
params: SortingOptions & Pick
+ signal?: AbortSignal
}) {
const notificationStore = useNotificationStore()
@@ -277,13 +283,15 @@ export const useAdminStore = defineStore('adminModule', {
params.like = payload.params.like.trim()
}
- const response = await AdminApi.getProjects(params)
+ const response = await AdminApi.getProjects(params, payload.signal)
this.projects.items = response.data.items
this.projects.count = response.data.count
} catch (e) {
- notificationStore.error({
- text: 'Failed to fetch projects'
- })
+ if (!axios.isCancel(e)) {
+ notificationStore.error({
+ text: 'Failed to fetch projects'
+ })
+ }
} finally {
this.projects.loading = false
}
From 6e4b3930e370188d3fa6afa74672e583ec2e3e1d Mon Sep 17 00:00:00 2001
From: Martin Varga
Date: Tue, 5 May 2026 08:33:47 +0200
Subject: [PATCH 03/16] More fixes to avoid N+1, mostly in serialization
schemas
Use joins or batch precalculation and cached_properties to avoid multiple unbound DB queries.
---
server/mergin/sync/models.py | 16 +++-
server/mergin/sync/public_api_controller.py | 82 ++++++++++++++++++---
server/mergin/sync/schemas.py | 38 ++++------
3 files changed, 103 insertions(+), 33 deletions(-)
diff --git a/server/mergin/sync/models.py b/server/mergin/sync/models.py
index ca1ab926..3b32cda9 100644
--- a/server/mergin/sync/models.py
+++ b/server/mergin/sync/models.py
@@ -6,6 +6,7 @@
import json
import logging
import os
+import re
import threading
import time
import uuid
@@ -138,6 +139,12 @@ def workspace(self):
project_workspace = current_app.ws_handler.get(self.workspace_id)
return project_workspace
+ @cached_property
+ def _has_conflict(self) -> bool:
+ """True if any current project file matches a known conflict-copy pattern."""
+ pattern = r"(\.gpkg|\.qgs|.qgz)(.*conflict.*)|( \(.*conflict.*)"
+ return any(re.search(pattern, f.path) for f in self.files)
+
def get_latest_files_cache(self) -> List[int]:
"""Get latest file history ids either from cached table or calculate them on the fly"""
if self.latest_project_files.file_history_ids is not None:
@@ -740,10 +747,11 @@ def changes(
.join(FileHistory.version)
.join(ProjectVersion.project)
.options(
+ contains_eager(FileHistory.file).load_only(ProjectFilePath.path),
contains_eager(FileHistory.version)
.load_only(ProjectVersion.name, ProjectVersion.project_id)
.contains_eager(ProjectVersion.project)
- .load_only(Project.storage_params)
+ .load_only(Project.storage_params),
)
.filter(
ProjectFilePath.project_id == project_id,
@@ -1811,11 +1819,15 @@ def diff_summary(self):
def changes_count(self) -> Dict:
"""Return number of changes by type"""
- query = f"SELECT change, COUNT(change) FROM file_history WHERE version_id = :version_id GROUP BY change;"
+ query = "SELECT change, COUNT(change) FROM file_history WHERE version_id = :version_id GROUP BY change;"
params = {"version_id": self.id}
result = db.session.execute(text(query), params).fetchall()
return {row[0]: row[1] for row in result}
+ @cached_property
+ def _changes_count(self) -> Dict:
+ return self.changes_count()
+
@property
def zip_path(self):
return os.path.join(
diff --git a/server/mergin/sync/public_api_controller.py b/server/mergin/sync/public_api_controller.py
index a3457e0a..90c71945 100644
--- a/server/mergin/sync/public_api_controller.py
+++ b/server/mergin/sync/public_api_controller.py
@@ -24,9 +24,10 @@
)
from pygeodiff import GeoDiffLibError
from flask_login import current_user
-from sqlalchemy import and_, desc, asc, tuple_
+import re
+from sqlalchemy import and_, desc, asc, text, tuple_
from sqlalchemy.exc import IntegrityError, SQLAlchemyError
-from sqlalchemy.orm import contains_eager, joinedload, load_only
+from sqlalchemy.orm import contains_eager, joinedload, load_only, selectinload
from gevent import sleep
import base64
from werkzeug.exceptions import HTTPException, Conflict
@@ -502,6 +503,8 @@ def get_paginated_project_versions(
project = require_project(namespace, project_name, ProjectPermissions.Read)
query = ProjectVersion.query.filter(
and_(ProjectVersion.project_id == project.id, ProjectVersion.name != 0)
+ ).options(
+ joinedload(ProjectVersion.project).load_only(Project.name, Project.workspace_id)
)
query = (
query.order_by(desc(ProjectVersion.name))
@@ -511,11 +514,59 @@ def get_paginated_project_versions(
paginate = query.paginate(page=page, per_page=per_page)
result = paginate.items
total = paginate.total
- versions = ProjectVersionListSchema(many=True).dump(result)
+
+ # batch-resolve workspace names for the page
+ ws_ids = {v.project.workspace_id for v in result}
+ workspaces_map = {w.id: w.name for w in current_app.ws_handler.get_by_ids(ws_ids)}
+
+ # batch-compute change counts for all versions in the page in one query
+ if result:
+ version_ids = [v.id for v in result]
+ rows = db.session.execute(
+ text(
+ "SELECT version_id, change, COUNT(change) AS cnt"
+ " FROM file_history"
+ " WHERE version_id = ANY(:ids)"
+ " GROUP BY version_id, change"
+ ),
+ {"ids": version_ids},
+ ).fetchall()
+ counts_map = {}
+ for row in rows:
+ counts_map.setdefault(row.version_id, {})[row.change] = row.cnt
+ for v in result:
+ v.__dict__["_changes_count"] = counts_map.get(v.id, {})
+
+ ctx = {"workspaces_map": workspaces_map}
+ versions = ProjectVersionListSchema(many=True, context=ctx).dump(result)
data = {"versions": versions, "count": total}
return data, 200
+def _precompute_has_conflict(projects):
+ """Pre-populate _has_conflict on each project using a single SQL query."""
+ if not projects:
+ return
+ conflict_regex = r"(\.gpkg|\.qgs|.qgz)(.*conflict.*)|( \(.*conflict.*)"
+ rows = db.session.execute(
+ text(
+ """
+ SELECT DISTINCT lpf.project_id
+ FROM latest_project_files lpf
+ CROSS JOIN unnest(lpf.file_history_ids) AS fh_id
+ JOIN file_history fh ON fh.id = fh_id
+ JOIN project_file_path fp ON fp.id = fh.file_path_id
+ WHERE lpf.project_id = ANY(:project_ids)
+ AND fp.path ~ :pattern
+ """
+ ),
+ {"project_ids": [p.id for p in projects], "pattern": conflict_regex},
+ ).fetchall()
+ conflict_ids = {row.project_id for row in rows}
+ for p in projects:
+ p.__dict__["_has_conflict"] = p.id in conflict_ids
+
+
def get_projects_by_names(): # noqa: E501
"""List mergin projects specified by list of projects with namespaces and names
@@ -529,10 +580,13 @@ def get_projects_by_names(): # noqa: E501
# batch-resolve workspaces by name (one DB query for DB-backed handlers)
unique_ws_names = {
- key.split("/")[0] for key in list_of_projects if len(key.split("/")) == 2
+ key.split("/")[0].lower()
+ for key in list_of_projects
+ if len(key.split("/")) == 2
}
workspaces_by_name = {
- ws.name: ws for ws in current_app.ws_handler.get_by_names(unique_ws_names)
+ ws.name.lower(): ws
+ for ws in current_app.ws_handler.get_by_names(unique_ws_names)
}
results = {}
@@ -542,17 +596,19 @@ def get_projects_by_names(): # noqa: E501
if len(parts) != 2:
results[key] = {"error": 404}
continue
- workspace = workspaces_by_name.get(parts[0])
+ workspace = workspaces_by_name.get(parts[0].lower())
if not workspace:
results[key] = {"error": 404}
continue
valid_projects.append((key, workspace, parts[1]))
if valid_projects:
- # batch-fetch all requested projects in one query
+ # batch-fetch all requested projects, eagerly loading project_users so
+ # members_by_role / get_role don't trigger per-project lazy loads
ws_name_pairs = [(ws.id, name) for _, ws, name in valid_projects]
found_projects = (
projects_query(ProjectPermissions.Read, as_admin=False)
+ .options(selectinload(Project.project_users))
.filter(tuple_(Project.workspace_id, Project.name).in_(ws_name_pairs))
.all()
)
@@ -570,6 +626,9 @@ def get_projects_by_names(): # noqa: E501
workspaces_map = {
w.id: w.name for w in current_app.ws_handler.get_by_ids(ws_ids)
}
+
+ _precompute_has_conflict(found_projects)
+
ctx = {"users_map": users_map, "workspaces_map": workspaces_map}
for key, workspace, name in valid_projects:
@@ -602,9 +661,11 @@ def get_projects_by_uuids(uuids): # noqa: E501
projects = (
projects_query(ProjectPermissions.Read, as_admin=False)
+ .options(selectinload(Project.project_users))
.filter(Project.id.in_(proj_ids))
.all()
)
+ _precompute_has_conflict(projects)
ws_ids = set([p.workspace_id for p in projects])
projects_ids = [p.id for p in projects]
users_map = {
@@ -686,9 +747,12 @@ def get_paginated_projects(
public,
only_public,
)
- pagination = projects.paginate(page=page, per_page=per_page)
+ pagination = projects.options(selectinload(Project.project_users)).paginate(
+ page=page, per_page=per_page
+ )
result = pagination.items
total = pagination.total
+ _precompute_has_conflict(result)
# create user map id:username passed to project schema to minimize queries to db
projects_ids = [p.id for p in result]
@@ -699,7 +763,7 @@ def get_paginated_projects(
.filter(ProjectUser.project_id.in_(projects_ids))
.all()
}
- ws_ids = [p.workspace_id for p in projects]
+ ws_ids = [p.workspace_id for p in result]
workspaces_map = {w.id: w.name for w in current_app.ws_handler.get_by_ids(ws_ids)}
ctx = {"users_map": users_map, "workspaces_map": workspaces_map}
sleep(
diff --git a/server/mergin/sync/schemas.py b/server/mergin/sync/schemas.py
index da18f7db..c2184c62 100644
--- a/server/mergin/sync/schemas.py
+++ b/server/mergin/sync/schemas.py
@@ -2,7 +2,6 @@
#
# SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-MerginMaps-Commercial
-import re
from marshmallow import fields, ValidationError, Schema, post_dump
from flask_login import current_user
from flask import current_app
@@ -192,7 +191,7 @@ class ProjectListSchema(ma.SQLAlchemyAutoSchema):
id = fields.UUID()
name = fields.Str()
namespace = fields.Method("get_workspace_name")
- access = fields.Function(lambda obj: ProjectAccessSchema().dump(obj))
+ access = fields.Method("get_access")
permissions = fields.Function(project_user_permissions)
version = fields.Function(lambda obj: ProjectVersion.to_v_name(obj.latest_version))
updated = fields.Method("get_updated")
@@ -200,22 +199,14 @@ class ProjectListSchema(ma.SQLAlchemyAutoSchema):
creator = fields.Integer(attribute="creator_id")
disk_usage = fields.Integer()
tags = fields.List(fields.Str())
- has_conflict = fields.Method("get_has_conflict")
+ has_conflict = fields.Function(lambda obj: obj._has_conflict)
+
+ def get_access(self, obj):
+ return ProjectAccessSchema(context=self.context).dump(obj)
def get_updated(self, obj):
return obj.updated if obj.updated else obj.created
- def get_has_conflict(self, obj):
- """Check if there is any conflict file in project generated by client
- Patterns to check:
- - file.[gpkg|qgs|qgz]_conflict_copy (older convention)
- - file.gpkg_rebase_conflicts (older convention)
- - file (conflicted copy, user vx).*
- - file (edit conflict, user vx).json
- """
- regex = r"(\.gpkg|\.qgs|.qgz)(.*conflict.*)|( \(.*conflict.*)"
- return any(re.search(regex, file.path) for file in obj.files)
-
def get_workspace_name(self, obj):
"""Discover ProjectListSchema workspace name"""
try:
@@ -368,22 +359,25 @@ class ProjectAccessDetailSchema(Schema):
class ProjectVersionListSchema(ma.SQLAlchemyAutoSchema):
project_name = fields.Function(lambda obj: obj.project.name)
- namespace = fields.Function(lambda obj: obj.project.workspace.name)
+ namespace = fields.Method("get_namespace")
name = fields.Function(lambda obj: ProjectVersion.to_v_name(obj.name))
author = fields.String(attribute="author.username")
created = DateTimeWithZ()
changes = fields.Method("_changes")
project_size = fields.Integer()
+ def get_namespace(self, obj):
+ workspaces_map = self.context.get("workspaces_map", {})
+ return workspaces_map.get(obj.project.workspace_id, "")
+
def _changes(self, obj):
- result = obj.changes_count()
- data = {
- "added": result.get(PushChangeType.CREATE.value, 0),
- "updated": result.get(PushChangeType.UPDATE.value, 0),
- "updated_diff": result.get(PushChangeType.UPDATE_DIFF.value, 0),
- "removed": result.get(PushChangeType.DELETE.value, 0),
+ counts = obj._changes_count
+ return {
+ "added": counts.get(PushChangeType.CREATE.value, 0),
+ "updated": counts.get(PushChangeType.UPDATE.value, 0),
+ "updated_diff": counts.get(PushChangeType.UPDATE_DIFF.value, 0),
+ "removed": counts.get(PushChangeType.DELETE.value, 0),
}
- return data
class Meta:
model = ProjectVersion
From 28a2c83a4c81633ac2c669fe278fe22a1b98c71a Mon Sep 17 00:00:00 2001
From: Martin Varga
Date: Tue, 5 May 2026 09:05:54 +0200
Subject: [PATCH 04/16] Fix tests
---
server/mergin/tests/test_project_controller.py | 5 +++++
1 file changed, 5 insertions(+)
diff --git a/server/mergin/tests/test_project_controller.py b/server/mergin/tests/test_project_controller.py
index f8a99188..d1f1afd6 100644
--- a/server/mergin/tests/test_project_controller.py
+++ b/server/mergin/tests/test_project_controller.py
@@ -2166,7 +2166,12 @@ def test_project_conflict_files(diff_project, file):
}
]
}
+ project_id = diff_project.id
_ = add_project_version(diff_project, changes)
+ # expunge so the identity map releases the instance; re-query gives a fresh
+ # object without the stale cached_property value
+ db.session.expunge(diff_project)
+ diff_project = db.session.get(Project, project_id)
project_info = ProjectListSchema(only=("has_conflict",), context=ctx).dump(
diff_project
)
From 9a88a5a8171d4c874cafd0fbd82ce2771959233e Mon Sep 17 00:00:00 2001
From: Richard Kello
Date: Fri, 15 May 2026 14:23:20 +0200
Subject: [PATCH 05/16] Update code with use of reusables, fix some checks
---
.../admin/components/AccountsTable.vue | 218 +++--------
.../admin/components/AdminProjectsTable.vue | 355 +++++++-----------
.../themes/mm-theme-light/_extensions.scss | 9 +
.../lib/src/common/composables/index.ts | 2 +
.../common/composables/useDataTableSearch.ts | 107 ++++++
web-app/packages/lib/src/mm-theme.ts | 2 +-
6 files changed, 308 insertions(+), 385 deletions(-)
create mode 100644 web-app/packages/lib/src/common/composables/useDataTableSearch.ts
diff --git a/web-app/packages/admin-lib/src/modules/admin/components/AccountsTable.vue b/web-app/packages/admin-lib/src/modules/admin/components/AccountsTable.vue
index ad312fc7..b52e4c35 100644
--- a/web-app/packages/admin-lib/src/modules/admin/components/AccountsTable.vue
+++ b/web-app/packages/admin-lib/src/modules/admin/components/AccountsTable.vue
@@ -24,7 +24,7 @@
@@ -44,49 +44,45 @@
:first="(options.page - 1) * options.itemsPerPage"
:sort-field="options.sortBy[0]"
:sort-order="options.sortDesc[0] ? -1 : 1"
+ :rowHover="true"
removableSort
reorderable-columns
@page="onPage"
- @row-click="rowClick"
@sort="onSort"
data-cy="accounts-table"
>
-
-
-
-
- {{ slotProps.data.username }}
-
-
-
-
-
-
+
+
+
+ {{ data.username }}
+
+
+
+
+
+
+ {{ data.email }}
+
+
+
+
+
+
+ {{ data.profile?.name }}
+
+
+
+
+
+
+
-
-
-
-
+
+
+
import {
PaginatedUsersParams,
+ useDataTableSearch,
useDialogStore,
- TableDataHeader,
AppContainer,
AppSection
} from '@mergin/lib'
-import debounce from 'lodash/debounce'
-import { mapActions, mapState } from 'pinia'
-import {
- DataTablePageEvent,
- DataTableRowClickEvent,
- DataTableSortEvent
-} from 'primevue/datatable'
+import { mapState } from 'pinia'
import { defineComponent } from 'vue'
import { AdminRoutes } from '@/modules'
@@ -130,134 +120,50 @@ export default defineComponent({
AppContainer,
AppSection
},
- data() {
+ setup() {
+ const adminStore = useAdminStore()
+ const dialogStore = useDialogStore()
+
+ const tableSearch = useDataTableSearch({
+ defaultSortBy: 'username',
+ defaultSortDesc: false
+ })
+
+ tableSearch.setFetchFn((signal) => {
+ const { options, search } = tableSearch
+ const params: PaginatedUsersParams = {
+ page: options.page,
+ per_page: options.itemsPerPage
+ }
+ if (options.sortBy[0]) {
+ params.descending = options.sortDesc[0]
+ params.order_by = options.sortBy[0]
+ }
+ if (search.value) params.like = search.value.trim()
+ adminStore.fetchUsers({ params, signal })
+ })
+
return {
- options: {
- sortBy: ['username'],
- sortDesc: [false],
- itemsPerPage: 20,
- page: 1,
- perPageOptions: [20, 50, 100]
- },
- searchByName: '',
- headers: [
- { field: 'username', header: 'Username', sortable: true },
- { field: 'email', header: 'Email', sortable: true },
- { field: 'profile.name', header: 'Full name' },
- { field: 'active', header: 'Active' }
- ] as TableDataHeader[],
- abortController: null as AbortController | null
+ ...tableSearch,
+ show: dialogStore.show.bind(dialogStore)
}
},
computed: {
...mapState(useAdminStore, ['users', 'loading'])
},
created() {
- // Restore any search/sort/page state from the URL before the first fetch
this.initFromQuery()
- // Delay search-triggered fetches so rapid typing doesn't spam the API
- this.onSearch = debounce(this.onSearch, 500)
this.doFetch()
},
methods: {
- ...mapActions(useAdminStore, ['fetchUsers']),
- ...mapActions(useDialogStore, ['show']),
-
- // Seed local state from URL query params so the page is shareable / survives navigation
- initFromQuery() {
- const q = this.$route.query
- if (q.q) this.searchByName = String(q.q)
- if (q.page) this.options.page = Number(q.page)
- if (q.per_page) this.options.itemsPerPage = Number(q.per_page)
- if (q.order_by) this.options.sortBy[0] = String(q.order_by)
- if (q.desc) this.options.sortDesc[0] = q.desc === 'true'
- },
-
- // Reflect current search/sort/page state into the URL (defaults are omitted to keep URLs clean)
- updateQuery() {
- const query: Record = {}
- if (this.searchByName) query.q = this.searchByName
- if (this.options.page > 1) query.page = String(this.options.page)
- if (this.options.itemsPerPage !== 20)
- query.per_page = String(this.options.itemsPerPage)
- if (this.options.sortBy[0] && this.options.sortBy[0] !== 'username')
- query.order_by = this.options.sortBy[0]
- if (this.options.sortDesc[0]) query.desc = 'true'
- // replace (not push) so back-button skips intermediate search states
- this.$router.replace({ query })
- },
-
- // Single entry point for all fetches: cancels any in-flight request, syncs the URL, then fetches
- doFetch() {
- // Abort the previous request so a stale slower response can't overwrite a newer one
- this.abortController?.abort()
- this.abortController = new AbortController()
- this.updateQuery()
- this.fetchUsers({
- params: this.getParams(),
- signal: this.abortController.signal
- })
- },
-
- // Called on every keystroke (debounced); resets to page 1 so results start from the beginning
- onSearch() {
- this.options.page = 1
- this.doFetch()
- },
-
- getParams(): PaginatedUsersParams {
- const params = {
- page: this.options.page,
- per_page: this.options.itemsPerPage
- } as PaginatedUsersParams
- if (this.options.sortBy[0]) {
- params.descending = this.options.sortDesc[0]
- params.order_by = this.options.sortBy[0]
- }
- if (this.searchByName) {
- params.like = this.searchByName.trim()
- }
- return params
- },
-
- onRefresh() {
- this.doFetch()
- },
-
- onPage(event: DataTablePageEvent) {
- this.options.page = event.page + 1
- this.options.itemsPerPage = event.rows
- this.doFetch()
- },
-
- onSort(event: DataTableSortEvent) {
- this.options.sortBy[0] = event.sortField?.toString()
- this.options.sortDesc[0] = event.sortOrder < 1
- this.doFetch()
- },
-
- rowClick(event: DataTableRowClickEvent) {
- const originalEvent = event.originalEvent as MouseEvent
- // Let the browser handle clicks that originate from a link inside the row (e.g. username column)
- if ((originalEvent.target as HTMLElement).closest('a')) return
-
- const location = {
- name: AdminRoutes.ACCOUNT,
- params: { username: event.data.username }
- }
- // Ctrl/Cmd/Shift+click opens in a new tab; plain click navigates in the same tab
- if (originalEvent.ctrlKey || originalEvent.metaKey || originalEvent.shiftKey) {
- window.open(this.$router.resolve(location).href, '_blank')
- } else {
- this.$router.push(location)
- }
+ accountRoute(data) {
+ return { name: AdminRoutes.ACCOUNT, params: { username: data.username } }
},
createUserDialog() {
const dialog = { maxWidth: 500, header: 'Create user' }
const listeners = {
success: () => {
- // After creating a user, go back to page 1 so the new account is visible
this.options.page = 1
this.doFetch()
}
diff --git a/web-app/packages/admin-lib/src/modules/admin/components/AdminProjectsTable.vue b/web-app/packages/admin-lib/src/modules/admin/components/AdminProjectsTable.vue
index 7af70914..6c268652 100644
--- a/web-app/packages/admin-lib/src/modules/admin/components/AdminProjectsTable.vue
+++ b/web-app/packages/admin-lib/src/modules/admin/components/AdminProjectsTable.vue
@@ -43,124 +43,118 @@ SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-MerginMaps-Commercial
:first="(options.page - 1) * options.itemsPerPage"
:sortField="options.sortBy[0]"
:sortOrder="options.sortDesc[0] ? -1 : 1"
+ :rowHover="true"
+ :row-class="(data) => (data.removed_at ? 'opacity-80' : '')"
removableSort
reorderable-columns
@page="onPage"
@sort="onSort"
- @row-click="rowClick"
data-cy="projects-table"
- :row-class="(data) => (data.removed_at ? 'opacity-80' : '')"
>
-
-
-
- {{ slotProps.data.workspace }}
-
-
+
+
+
+ {{ data.workspace }}
+
+ {{ data.workspace }}
+
+
-
-
- {{
- slotProps.data.name
- }}
-
- {{ slotProps.data.name }}
-
-
-
+
+
+
+ {{ data.name }}
+
+ {{ data.name }}
+
+
-
-
-
- {{ $filters.timediff(slotProps.data.updated) }}
+
+
+
+
+ {{ $filters.timediff(data.updated) }}
-
-
+
+
+ {{ $filters.timediff(data.updated) }}
+
+
+
-
-
- {{ $filters.filesize(slotProps.data.disk_usage, 'MB') }}
-
-
+
+
+
+ {{ $filters.filesize(data.disk_usage, 'MB') }}
+
+ {{ $filters.filesize(data.disk_usage, 'MB') }}
+
+
-
-
-
- {{ $filters.timediff(slotProps.data.removed_at) }}
-
-
-
+
+
+
+ {{ $filters.timediff(data.removed_at) }}
+
+
+
+
+
+
+ {{ data.removed_by }}
+
+
-
-
-
-
+
+
+
+
+
-
-
import {
ConfirmDialog,
+ useDataTableSearch,
useDialogStore,
useNotificationStore,
SortingOptions,
- TableDataHeader,
AppSection,
AppContainer,
ConfirmDialogProps
} from '@mergin/lib'
-import debounce from 'lodash/debounce'
-import { mapActions, mapState } from 'pinia'
-import {
- DataTablePageEvent,
- DataTableRowClickEvent,
- DataTableSortEvent
-} from 'primevue/datatable'
+import { mapState, mapActions } from 'pinia'
import { PropType, defineComponent } from 'vue'
import { AdminRoutes, useAdminStore } from '@/main'
@@ -219,130 +207,45 @@ export default defineComponent({
})
}
},
- data() {
+ setup(props) {
+ const adminStore = useAdminStore()
+ const dialogStore = useDialogStore()
+ const notificationStore = useNotificationStore()
+
+ const tableSearch = useDataTableSearch({
+ defaultSortBy: props.initialOptions.sortBy[0],
+ defaultSortDesc: props.initialOptions.sortDesc[0]
+ })
+
+ tableSearch.setFetchFn((signal) => {
+ const { options, search } = tableSearch
+ adminStore.getProjects({
+ params: { ...options, like: search.value },
+ signal
+ })
+ })
+
return {
- options: Object.assign({}, this.initialOptions),
- search: '',
- abortController: null as AbortController | null
+ ...tableSearch,
+ showDialog: dialogStore.show.bind(dialogStore),
+ error: notificationStore.error.bind(notificationStore),
+ show: notificationStore.show.bind(notificationStore)
}
},
computed: {
- ...mapState(useAdminStore, ['projects']),
- headers(): TableDataHeader[] {
- return [
- ...(this.showNamespace
- ? [
- {
- header: 'Workspace',
- field: 'workspace',
- sortable: true
- }
- ]
- : []),
- { header: 'Name', field: 'name', sortable: true },
- { header: 'Last Update', field: 'updated', sortable: true },
- { header: 'Size', field: 'disk_usage', sortable: true },
- { header: 'Scheduled removal at', field: 'removed_at', sortable: true },
- { header: 'Removed by', field: 'removed_by', sortable: true },
- { header: '', field: 'buttons', sortable: false }
- ]
- }
+ ...mapState(useAdminStore, ['projects'])
},
created() {
- // Restore any search/sort/page state from the URL before the first fetch
this.initFromQuery()
- // Delay search-triggered fetches so rapid typing doesn't spam the API
- this.onSearch = debounce(this.onSearch, 500)
this.doFetch()
},
methods: {
- ...mapActions(useDialogStore, { showDialog: 'show' }),
- ...mapActions(useNotificationStore, ['error', 'show']),
- ...mapActions(useAdminStore, [
- 'getProjects',
- 'restoreProject',
- 'deleteProject'
- ]),
-
- // Seed local state from URL query params so the page is shareable / survives navigation
- initFromQuery() {
- const q = this.$route.query
- if (q.q) this.search = String(q.q)
- if (q.page) this.options.page = Number(q.page)
- if (q.per_page) this.options.itemsPerPage = Number(q.per_page)
- if (q.order_by) this.options.sortBy[0] = String(q.order_by)
- if (q.desc) this.options.sortDesc[0] = q.desc === 'true'
- },
-
- // Reflect current search/sort/page state into the URL (defaults are omitted to keep URLs clean)
- updateQuery() {
- const query: Record = {}
- if (this.search) query.q = this.search
- if (this.options.page > 1) query.page = String(this.options.page)
- if (this.options.itemsPerPage !== 20)
- query.per_page = String(this.options.itemsPerPage)
- if (this.options.sortBy[0] && this.options.sortBy[0] !== 'updated')
- query.order_by = this.options.sortBy[0]
- if (!this.options.sortDesc[0]) query.desc = 'false'
- // replace (not push) so back-button skips intermediate search states
- this.$router.replace({ query })
- },
-
- // Single entry point for all fetches: cancels any in-flight request, syncs the URL, then fetches
- doFetch() {
- // Abort the previous request so a stale slower response can't overwrite a newer one
- this.abortController?.abort()
- this.abortController = new AbortController()
- this.updateQuery()
- this.getProjects({
- params: { ...this.options, like: this.search },
- signal: this.abortController.signal
- })
- },
-
- paginating(options) {
- this.options = options
- this.doFetch()
- },
-
- // Called on every keystroke (debounced); resets to page 1 so results start from the beginning
- onSearch() {
- this.options.page = 1
- this.doFetch()
- },
-
- onPage(event: DataTablePageEvent) {
- this.options.page = event.page + 1
- this.options.itemsPerPage = event.rows
- this.doFetch()
- },
+ ...mapActions(useAdminStore, ['restoreProject', 'deleteProject']),
- onSort(event: DataTableSortEvent) {
- this.options.sortBy[0] = event.sortField?.toString()
- this.options.sortDesc[0] = event.sortOrder < 1
- this.doFetch()
- },
-
- rowClick(event: DataTableRowClickEvent) {
- // Removed projects have no detail view, only Restore/Delete buttons
- if (event.data.removed_at) return
-
- const originalEvent = event.originalEvent as MouseEvent
- // Let the browser handle clicks that originate from a link or button inside the row
- if ((originalEvent.target as HTMLElement).closest('a, button')) return
-
- const location = {
+ projectRoute(data) {
+ return {
name: AdminRoutes.PROJECT,
- params: {
- namespace: event.data.workspace,
- projectName: event.data.name
- }
- }
- // Ctrl/Cmd/Shift+click opens in a new tab; plain click navigates in the same tab
- if (originalEvent.ctrlKey || originalEvent.metaKey || originalEvent.shiftKey) {
- window.open(this.$router.resolve(location).href, '_blank')
- } else {
- this.$router.push(location)
+ params: { namespace: data.workspace, projectName: data.name }
}
},
@@ -386,10 +289,6 @@ export default defineComponent({
component: ConfirmDialog,
params: { props, listeners, dialog: { header: 'Delete project' } }
})
- },
-
- onRefresh() {
- this.doFetch()
}
}
})
diff --git a/web-app/packages/lib/src/assets/sass/themes/mm-theme-light/_extensions.scss b/web-app/packages/lib/src/assets/sass/themes/mm-theme-light/_extensions.scss
index ccf252bb..156473c4 100644
--- a/web-app/packages/lib/src/assets/sass/themes/mm-theme-light/_extensions.scss
+++ b/web-app/packages/lib/src/assets/sass/themes/mm-theme-light/_extensions.scss
@@ -133,3 +133,12 @@ img {
text-decoration-line: underline;
text-decoration-style: dotted;
}
+
+td:has(.dt-row-link) {
+ padding: 0;
+}
+
+.dt-row-link {
+ display: block;
+ padding: $tableBodyCellPadding;
+}
diff --git a/web-app/packages/lib/src/common/composables/index.ts b/web-app/packages/lib/src/common/composables/index.ts
index dd4fe405..86cca258 100644
--- a/web-app/packages/lib/src/common/composables/index.ts
+++ b/web-app/packages/lib/src/common/composables/index.ts
@@ -3,3 +3,5 @@
// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-MerginMaps-Commercial
export { default as useRouterTitle } from './use_router_title'
+export { useDataTableSearch } from './useDataTableSearch'
+export type { DataTableSearchOptions } from './useDataTableSearch'
diff --git a/web-app/packages/lib/src/common/composables/useDataTableSearch.ts b/web-app/packages/lib/src/common/composables/useDataTableSearch.ts
new file mode 100644
index 00000000..4743f192
--- /dev/null
+++ b/web-app/packages/lib/src/common/composables/useDataTableSearch.ts
@@ -0,0 +1,107 @@
+// Copyright (C) Lutra Consulting Limited
+//
+// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-MerginMaps-Commercial
+
+import debounce from 'lodash/debounce'
+import { ref, reactive } from 'vue'
+import { useRoute, useRouter } from 'vue-router'
+import {
+ DataTablePageEvent,
+ DataTableSortEvent
+} from 'primevue/datatable'
+
+export interface DataTableSearchOptions {
+ defaultSortBy?: string
+ defaultSortDesc?: boolean
+}
+
+/**
+ * Shared search/pagination/URL-sync logic for lazy-loaded admin data tables.
+ *
+ * Call setFetchFn() immediately after setup to register the table-specific
+ * fetch action; all event handlers will invoke it automatically.
+ */
+export function useDataTableSearch(opts: DataTableSearchOptions = {}) {
+ const { defaultSortBy = '', defaultSortDesc = false } = opts
+
+ const route = useRoute()
+ const router = useRouter()
+
+ const search = ref('')
+ const options = reactive({
+ sortBy: [defaultSortBy] as string[],
+ sortDesc: [defaultSortDesc] as boolean[],
+ itemsPerPage: 20,
+ page: 1,
+ perPageOptions: [20, 50, 100]
+ })
+ const abortController = ref(null)
+ const fetchFn = ref<((signal: AbortSignal) => void) | null>(null)
+
+ function setFetchFn(fn: (signal: AbortSignal) => void) {
+ fetchFn.value = fn
+ }
+
+ function initFromQuery() {
+ const q = route.query
+ if (q.q) search.value = String(q.q)
+ if (q.page) options.page = Number(q.page)
+ if (q.per_page) options.itemsPerPage = Number(q.per_page)
+ if (q.order_by) options.sortBy[0] = String(q.order_by)
+ if (q.desc) options.sortDesc[0] = q.desc === 'true'
+ }
+
+ function updateQuery() {
+ const query: Record = {}
+ if (search.value) query.q = search.value
+ if (options.page > 1) query.page = String(options.page)
+ if (options.itemsPerPage !== 20)
+ query.per_page = String(options.itemsPerPage)
+ if (options.sortBy[0] && options.sortBy[0] !== defaultSortBy)
+ query.order_by = options.sortBy[0]
+ if (options.sortDesc[0] !== defaultSortDesc)
+ query.desc = String(options.sortDesc[0])
+ router.replace({ query })
+ }
+
+ function doFetch() {
+ abortController.value?.abort()
+ abortController.value = new AbortController()
+ updateQuery()
+ fetchFn.value?.(abortController.value.signal)
+ }
+
+ const onSearch = debounce(() => {
+ options.page = 1
+ doFetch()
+ }, 500)
+
+ function onPage(event: DataTablePageEvent) {
+ options.page = event.page + 1
+ options.itemsPerPage = event.rows
+ doFetch()
+ }
+
+ function onSort(event: DataTableSortEvent) {
+ options.sortBy[0] = event.sortField?.toString() ?? ''
+ options.sortDesc[0] = event.sortOrder < 1
+ doFetch()
+ }
+
+ function onRefresh() {
+ doFetch()
+ }
+
+ return {
+ search,
+ options,
+ abortController,
+ setFetchFn,
+ initFromQuery,
+ doFetch,
+ onSearch,
+ onPage,
+ onSort,
+ onRefresh
+ }
+}
diff --git a/web-app/packages/lib/src/mm-theme.ts b/web-app/packages/lib/src/mm-theme.ts
index 169ab5ca..8d6e8571 100644
--- a/web-app/packages/lib/src/mm-theme.ts
+++ b/web-app/packages/lib/src/mm-theme.ts
@@ -157,7 +157,7 @@ export default usePassThrough(
} as TagPassThroughOptions,
column: {
bodyCell: {
- class: 'pl-4 py-2'
+ // class: 'pl-4 py-2'
},
headerCell: {
class: 'pl-4 py-1',
From 414817fd85b631611607e72c5da5ec7568cc2fc5 Mon Sep 17 00:00:00 2001
From: Richard Kello
Date: Fri, 15 May 2026 17:05:04 +0200
Subject: [PATCH 06/16] Fix some prettier issues
---
.../lib/src/common/composables/useDataTableSearch.ts | 5 +----
1 file changed, 1 insertion(+), 4 deletions(-)
diff --git a/web-app/packages/lib/src/common/composables/useDataTableSearch.ts b/web-app/packages/lib/src/common/composables/useDataTableSearch.ts
index 4743f192..e04a2123 100644
--- a/web-app/packages/lib/src/common/composables/useDataTableSearch.ts
+++ b/web-app/packages/lib/src/common/composables/useDataTableSearch.ts
@@ -3,12 +3,9 @@
// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-MerginMaps-Commercial
import debounce from 'lodash/debounce'
+import { DataTablePageEvent, DataTableSortEvent } from 'primevue/datatable'
import { ref, reactive } from 'vue'
import { useRoute, useRouter } from 'vue-router'
-import {
- DataTablePageEvent,
- DataTableSortEvent
-} from 'primevue/datatable'
export interface DataTableSearchOptions {
defaultSortBy?: string
From 78c62b9da9a93eb59942084bca93a73c9a5f4dfe Mon Sep 17 00:00:00 2001
From: Richard Kello
Date: Wed, 20 May 2026 23:33:43 +0200
Subject: [PATCH 07/16] Add slot for WS table in admin for user profile
---
.../admin-lib/src/modules/admin/views/AccountDetailView.vue | 1 +
1 file changed, 1 insertion(+)
diff --git a/web-app/packages/admin-lib/src/modules/admin/views/AccountDetailView.vue b/web-app/packages/admin-lib/src/modules/admin/views/AccountDetailView.vue
index 9096e9bb..a5b708a0 100644
--- a/web-app/packages/admin-lib/src/modules/admin/views/AccountDetailView.vue
+++ b/web-app/packages/admin-lib/src/modules/admin/views/AccountDetailView.vue
@@ -63,6 +63,7 @@
+
Advanced
From 005bfc066c695f9110f08a4cc22bd2705eb6a642 Mon Sep 17 00:00:00 2001
From: Richard Kello
Date: Sun, 24 May 2026 16:09:36 +0200
Subject: [PATCH 08/16] Update compiled type declarations for
useDataTableSearch and TableDataHeader
---
.../admin/components/AccountsTable.vue | 76 ++++++----
.../admin/components/AdminProjectsTable.vue | 143 ++++++++++--------
.../modules/admin/views/AccountDetailView.vue | 4 +-
.../lib/src/common/components/types.ts | 4 +
.../common/composables/useDataTableSearch.ts | 11 +-
5 files changed, 141 insertions(+), 97 deletions(-)
diff --git a/web-app/packages/admin-lib/src/modules/admin/components/AccountsTable.vue b/web-app/packages/admin-lib/src/modules/admin/components/AccountsTable.vue
index b52e4c35..aee0ce4d 100644
--- a/web-app/packages/admin-lib/src/modules/admin/components/AccountsTable.vue
+++ b/web-app/packages/admin-lib/src/modules/admin/components/AccountsTable.vue
@@ -51,38 +51,28 @@
@sort="onSort"
data-cy="accounts-table"
>
-
-
-
- {{ data.username }}
-
-
-
-
-
-
- {{ data.email }}
-
-
-
-
-
-
- {{ data.profile?.name }}
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+ {{
+ fieldValue(data, header.field) ? 'Active' : 'Inactive'
+ }}
+ {{
+ fieldValue(data, header.field)
+ }}
+
+
+
+
import {
PaginatedUsersParams,
+ TableDataHeader,
useDataTableSearch,
useDialogStore,
AppContainer,
AppSection
} from '@mergin/lib'
+import get from 'lodash/get'
import { mapState } from 'pinia'
import { defineComponent } from 'vue'
@@ -114,6 +106,19 @@ import { AdminRoutes } from '@/modules'
import CreateUserForm from '@/modules/admin/components/CreateUserForm.vue'
import { useAdminStore } from '@/modules/admin/store'
+const headers: TableDataHeader[] = [
+ {
+ field: 'username',
+ header: 'Username',
+ sortable: true,
+ linked: true,
+ class: 'title-t4'
+ },
+ { field: 'email', header: 'Email', sortable: true, linked: true },
+ { field: 'profile.name', header: 'Full name', linked: true },
+ { field: 'active', header: 'Active', linked: true, type: 'boolean' }
+]
+
export default defineComponent({
name: 'AccountsTable',
components: {
@@ -145,7 +150,8 @@ export default defineComponent({
return {
...tableSearch,
- show: dialogStore.show.bind(dialogStore)
+ show: dialogStore.show.bind(dialogStore),
+ headers
}
},
computed: {
@@ -156,6 +162,10 @@ export default defineComponent({
this.doFetch()
},
methods: {
+ fieldValue(data: unknown, field: string) {
+ return get(data, field)
+ },
+
accountRoute(data) {
return { name: AdminRoutes.ACCOUNT, params: { username: data.username } }
},
diff --git a/web-app/packages/admin-lib/src/modules/admin/components/AdminProjectsTable.vue b/web-app/packages/admin-lib/src/modules/admin/components/AdminProjectsTable.vue
index 6c268652..d8502969 100644
--- a/web-app/packages/admin-lib/src/modules/admin/components/AdminProjectsTable.vue
+++ b/web-app/packages/admin-lib/src/modules/admin/components/AdminProjectsTable.vue
@@ -51,66 +51,39 @@ SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-MerginMaps-Commercial
@sort="onSort"
data-cy="projects-table"
>
-
-
-
- {{ data.workspace }}
-
- {{ data.workspace }}
-
-
-
-
-
-
- {{ data.name }}
-
- {{ data.name }}
-
-
-
-
-
-
-
- {{ $filters.timediff(data.updated) }}
+
+
+
+
+ {{ $filters.timediff(data[header.field]) }}
+ {{ cellContent(data, header) }}
+
+
+ {{ $filters.timediff(data[header.field]) }}
+ {{ cellContent(data, header) }}
-
-
- {{ $filters.timediff(data.updated) }}
-
-
-
-
-
-
-
- {{ $filters.filesize(data.disk_usage, 'MB') }}
-
- {{ $filters.filesize(data.disk_usage, 'MB') }}
-
-
+
+
+
import {
ConfirmDialog,
+ TableDataHeader,
useDataTableSearch,
useDialogStore,
useNotificationStore,
@@ -233,7 +207,46 @@ export default defineComponent({
}
},
computed: {
- ...mapState(useAdminStore, ['projects'])
+ ...mapState(useAdminStore, ['projects']),
+ headers(): TableDataHeader[] {
+ const cols: TableDataHeader[] = []
+ if (this.showNamespace) {
+ cols.push({
+ field: 'workspace',
+ header: 'Workspace',
+ sortable: true,
+ linked: true,
+ conditionalLink: 'removed_at'
+ })
+ }
+ cols.push(
+ {
+ field: 'name',
+ header: 'Name',
+ sortable: true,
+ linked: true,
+ class: 'font-semibold',
+ conditionalLink: 'removed_at'
+ },
+ {
+ field: 'updated',
+ header: 'Last Update',
+ sortable: true,
+ linked: true,
+ type: 'timediff',
+ conditionalLink: 'removed_at'
+ },
+ {
+ field: 'disk_usage',
+ header: 'Size',
+ sortable: true,
+ linked: true,
+ type: 'filesize',
+ conditionalLink: 'removed_at'
+ }
+ )
+ return cols
+ }
},
created() {
this.initFromQuery()
@@ -242,6 +255,16 @@ export default defineComponent({
methods: {
...mapActions(useAdminStore, ['restoreProject', 'deleteProject']),
+ cellContent(
+ data: Record,
+ header: TableDataHeader
+ ): string {
+ const val = data[header.field]
+ if (header.type === 'timediff') return this.$filters.timediff(val)
+ if (header.type === 'filesize') return this.$filters.filesize(val, 'MB')
+ return String(val ?? '')
+ },
+
projectRoute(data) {
return {
name: AdminRoutes.PROJECT,
diff --git a/web-app/packages/admin-lib/src/modules/admin/views/AccountDetailView.vue b/web-app/packages/admin-lib/src/modules/admin/views/AccountDetailView.vue
index 9096e9bb..18ceade0 100644
--- a/web-app/packages/admin-lib/src/modules/admin/views/AccountDetailView.vue
+++ b/web-app/packages/admin-lib/src/modules/admin/views/AccountDetailView.vue
@@ -44,7 +44,9 @@
>
{{ user?.email }}
-
+
diff --git a/web-app/packages/lib/src/common/components/types.ts b/web-app/packages/lib/src/common/components/types.ts
index 245bc52f..73340e8a 100644
--- a/web-app/packages/lib/src/common/components/types.ts
+++ b/web-app/packages/lib/src/common/components/types.ts
@@ -14,6 +14,10 @@ export interface TableDataHeader {
field: string
sortable?: boolean
width?: number
+ linked?: boolean
+ class?: string
+ type?: 'boolean' | 'filesize' | 'timediff'
+ conditionalLink?: string
}
export type TipMessageSeverity = 'info' | 'danger'
diff --git a/web-app/packages/lib/src/common/composables/useDataTableSearch.ts b/web-app/packages/lib/src/common/composables/useDataTableSearch.ts
index e04a2123..cad29b64 100644
--- a/web-app/packages/lib/src/common/composables/useDataTableSearch.ts
+++ b/web-app/packages/lib/src/common/composables/useDataTableSearch.ts
@@ -10,6 +10,7 @@ import { useRoute, useRouter } from 'vue-router'
export interface DataTableSearchOptions {
defaultSortBy?: string
defaultSortDesc?: boolean
+ defaultItemsPerPage?: number
}
/**
@@ -19,7 +20,11 @@ export interface DataTableSearchOptions {
* fetch action; all event handlers will invoke it automatically.
*/
export function useDataTableSearch(opts: DataTableSearchOptions = {}) {
- const { defaultSortBy = '', defaultSortDesc = false } = opts
+ const {
+ defaultSortBy = '',
+ defaultSortDesc = false,
+ defaultItemsPerPage = 20
+ } = opts
const route = useRoute()
const router = useRouter()
@@ -28,7 +33,7 @@ export function useDataTableSearch(opts: DataTableSearchOptions = {}) {
const options = reactive({
sortBy: [defaultSortBy] as string[],
sortDesc: [defaultSortDesc] as boolean[],
- itemsPerPage: 20,
+ itemsPerPage: defaultItemsPerPage,
page: 1,
perPageOptions: [20, 50, 100]
})
@@ -52,7 +57,7 @@ export function useDataTableSearch(opts: DataTableSearchOptions = {}) {
const query: Record
= {}
if (search.value) query.q = search.value
if (options.page > 1) query.page = String(options.page)
- if (options.itemsPerPage !== 20)
+ if (options.itemsPerPage !== defaultItemsPerPage)
query.per_page = String(options.itemsPerPage)
if (options.sortBy[0] && options.sortBy[0] !== defaultSortBy)
query.order_by = options.sortBy[0]
From 1f2d949f164e3f93dfb8e367b3605db58b5be884 Mon Sep 17 00:00:00 2001
From: Martin Varga
Date: Wed, 27 May 2026 09:40:05 +0200
Subject: [PATCH 09/16] Fix case sensitivity issue in project lookup
Remove .lower() transformation for workspace name lookup which was causing regression.
---
server/mergin/sync/public_api_controller.py | 10 +++-------
1 file changed, 3 insertions(+), 7 deletions(-)
diff --git a/server/mergin/sync/public_api_controller.py b/server/mergin/sync/public_api_controller.py
index 90c71945..8dbe1237 100644
--- a/server/mergin/sync/public_api_controller.py
+++ b/server/mergin/sync/public_api_controller.py
@@ -580,15 +580,11 @@ def get_projects_by_names(): # noqa: E501
# batch-resolve workspaces by name (one DB query for DB-backed handlers)
unique_ws_names = {
- key.split("/")[0].lower()
- for key in list_of_projects
- if len(key.split("/")) == 2
+ key.split("/")[0] for key in list_of_projects if len(key.split("/")) == 2
}
workspaces_by_name = {
- ws.name.lower(): ws
- for ws in current_app.ws_handler.get_by_names(unique_ws_names)
+ ws.name: ws for ws in current_app.ws_handler.get_by_names(unique_ws_names)
}
-
results = {}
valid_projects = [] # list of (key, workspace, project_name)
for key in list_of_projects:
@@ -596,7 +592,7 @@ def get_projects_by_names(): # noqa: E501
if len(parts) != 2:
results[key] = {"error": 404}
continue
- workspace = workspaces_by_name.get(parts[0].lower())
+ workspace = workspaces_by_name.get(parts[0])
if not workspace:
results[key] = {"error": 404}
continue
From b2692916f00fdfe8caf1c0cdf7bdb7963085965b Mon Sep 17 00:00:00 2001
From: Martin Varga
Date: Fri, 29 May 2026 14:16:18 +0200
Subject: [PATCH 10/16] Chore: add CI for alembic migration tests
---
.github/workflows/auto_tests.yml | 21 +-
server/mergin/test_migrations/__init__.py | 0
.../mergin/test_migrations/test_migrations.py | 38 +++
server/mergin/test_migrations/utils.py | 241 ++++++++++++++++++
...7f2185e2428_case_insensitive_unique_idx.py | 24 +-
.../3daefa84ce67_add_deployment_info.py | 4 +-
...9c566e7_migrate_away_from_jsonb_columns.py | 2 +-
.../dbd428cda965_migrate_to_jsonb.py | 37 ++-
8 files changed, 342 insertions(+), 25 deletions(-)
create mode 100644 server/mergin/test_migrations/__init__.py
create mode 100644 server/mergin/test_migrations/test_migrations.py
create mode 100644 server/mergin/test_migrations/utils.py
diff --git a/.github/workflows/auto_tests.yml b/.github/workflows/auto_tests.yml
index 4d78bd65..0f7596e3 100644
--- a/.github/workflows/auto_tests.yml
+++ b/.github/workflows/auto_tests.yml
@@ -3,9 +3,18 @@ name: Auto Tests
on: push
jobs:
- server_tests:
+ tests:
runs-on: ubuntu-24.04
+ strategy:
+ fail-fast: false
+ matrix:
+ include:
+ - suite: server
+ pytest_args: "-v --cov=mergin --cov-report=lcov mergin/tests"
+ - suite: migration
+ pytest_args: "-v mergin/test_migrations"
+
services:
postgres:
image: postgres:14
@@ -15,13 +24,18 @@ jobs:
POSTGRES_USER: postgres
ports:
- 5435:5432
- # Set health checks to wait until postgres has started
options: >-
--health-cmd pg_isready
--health-interval 10s
--health-timeout 5s
--health-retries 5
+ env:
+ DB_USER: postgres
+ DB_PASSWORD: postgres
+ DB_HOST: localhost
+ DB_PORT: 5435
+
steps:
- name: Check out repository
uses: actions/checkout@v3
@@ -36,9 +50,10 @@ jobs:
- name: Run tests
run: |
cd server
- pipenv run pytest -v --cov=mergin --cov-report=lcov mergin/tests
+ pipenv run pytest ${{ matrix.pytest_args }}
- name: Coveralls
+ if: matrix.suite == 'server'
uses: coverallsapp/github-action@v2
with:
base-path: server
diff --git a/server/mergin/test_migrations/__init__.py b/server/mergin/test_migrations/__init__.py
new file mode 100644
index 00000000..e69de29b
diff --git a/server/mergin/test_migrations/test_migrations.py b/server/mergin/test_migrations/test_migrations.py
new file mode 100644
index 00000000..8bfa1154
--- /dev/null
+++ b/server/mergin/test_migrations/test_migrations.py
@@ -0,0 +1,38 @@
+# Copyright (C) Lutra Consulting Limited
+#
+# SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-MerginMaps-Commercial
+
+from pathlib import Path
+
+from ..app import db
+from .utils import (
+ make_migration_app,
+ make_migration_engine,
+ ordered_revisions,
+ run_migration_lifecycle,
+)
+
+MIGRATIONS_DIR = str(Path(__file__).parents[2] / "migrations")
+# 1fcbea2a0f2c (drop_namespace_related_objects) has an intentional no-op downgrade
+# because removing namespace tables is irreversible. We stop there.
+DOWNGRADE_TARGET = "1fcbea2a0f2c"
+
+migration_engine = make_migration_engine("mergin_migration_test")
+migration_app = make_migration_app(
+ model_modules=[
+ "mergin.auth.models",
+ "mergin.stats.models",
+ "mergin.sync.models",
+ ]
+)
+
+
+def test_migration_lifecycle(migration_app, migration_engine):
+ """Exercise the full migration chain: empty DB → head (one step at a time) → schema check → partial downgrade."""
+ run_migration_lifecycle(
+ migration_engine,
+ MIGRATIONS_DIR,
+ ordered_revisions(MIGRATIONS_DIR),
+ db.metadata,
+ DOWNGRADE_TARGET,
+ )
diff --git a/server/mergin/test_migrations/utils.py b/server/mergin/test_migrations/utils.py
new file mode 100644
index 00000000..8f174a74
--- /dev/null
+++ b/server/mergin/test_migrations/utils.py
@@ -0,0 +1,241 @@
+# Copyright (C) Lutra Consulting Limited
+#
+# SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-MerginMaps-Commercial
+
+import pytest
+from alembic.autogenerate import compare_metadata
+from alembic.config import Config as AlembicConfig
+from alembic.runtime.migration import MigrationContext
+from alembic.script import ScriptDirectory
+from flask_migrate import downgrade, upgrade
+from pathlib import Path
+from sqlalchemy import create_engine, make_url, text
+
+# create_simple_app and Configuration are imported lazily inside their respective
+# factory functions to avoid triggering Configuration evaluation at module import
+# time (before pytest-dotenv has loaded .test.env into os.environ).
+
+_STRUCTURAL_DIFF_TYPES = (
+ "add_table",
+ "remove_table",
+ "add_column",
+ "remove_column",
+ "modify_nullable",
+ "add_index",
+ "remove_index",
+ "add_constraint",
+ "remove_constraint",
+)
+
+
+def assert_schema_consistent(engine, metadata, include_schemas=()):
+ """Assert ORM metadata matches the migrated schema — structural checks only.
+
+ Column types and server defaults are excluded: alembic's reflection of
+ PostgreSQL-specific types (JSONB, UUID, ARRAY …) and default expressions
+ produces false positives without an explicit allowlist.
+
+ include_schemas: additional PostgreSQL schemas beyond 'public' to verify.
+ Extra schemas are checked via direct inspection (table existence only) rather
+ than compare_metadata, because include_schemas=True in MigrationContext causes
+ public-schema tables to be reflected with an explicit "public" qualifier that
+ doesn't match ORM models with schema=None — producing spurious add_table diffs.
+ """
+ from sqlalchemy import inspect as sa_inspect
+
+ extra_schema_set = set(include_schemas)
+
+ def _table_schema(d):
+ """Extract the schema from a diff entry, or None."""
+ if len(d) < 2:
+ return None
+ obj = d[1]
+ # add_table / remove_table: obj is the Table
+ schema = getattr(obj, "schema", None)
+ if schema is not None:
+ return schema
+ # add_index / remove_index: obj is an Index whose .table has the schema
+ table = getattr(obj, "table", None)
+ return getattr(table, "schema", None)
+
+ # Standard structural check for the public (default) schema.
+ # Diffs involving tables in extra schemas are excluded here — they are
+ # checked separately below via direct inspection to avoid the schema-name
+ # mismatch that include_schemas=True causes in compare_metadata.
+ with engine.connect() as conn:
+ ctx = MigrationContext.configure(conn)
+ diff = compare_metadata(ctx, metadata)
+ checked = [
+ d
+ for d in diff
+ if d[0] in _STRUCTURAL_DIFF_TYPES and _table_schema(d) not in extra_schema_set
+ ]
+
+ # Table-existence check for each additional schema
+ if include_schemas:
+ inspector = sa_inspect(engine)
+ for schema in include_schemas:
+ existing = set(inspector.get_table_names(schema=schema))
+ expected = {
+ table.name
+ for table in metadata.tables.values()
+ if table.schema == schema
+ }
+ for name in sorted(expected - existing):
+ checked.append(("add_table", f"{schema}.{name}"))
+ for name in sorted(existing - expected):
+ checked.append(("remove_table", f"{schema}.{name}"))
+
+ assert not checked, (
+ "ORM models differ from the migrated schema — add a migration or update the filter:\n"
+ + "\n".join(str(d) for d in checked)
+ )
+
+
+def ordered_revisions(migrations_dir, head="head", version_locations=None):
+ """Return revision IDs in upgrade order (base → head).
+
+ Works for single-head (CE), single branch label (enterprise@head), and
+ multi-head tuples (("enterprise@head", "service@head")) alembic setups.
+ Pass version_locations as a colon-separated string when the script directory
+ has more than one branch folder.
+ """
+ cfg = AlembicConfig()
+ cfg.set_main_option("script_location", migrations_dir)
+ cfg.set_main_option(
+ "version_locations",
+ version_locations or str(Path(migrations_dir) / "community"),
+ )
+ cfg.set_main_option("path_separator", "os")
+ script = ScriptDirectory.from_config(cfg)
+ upper = tuple(head) if isinstance(head, (list, tuple)) else head
+ return [
+ rev.revision
+ for rev in reversed(list(script.iterate_revisions(upper=upper, lower="base")))
+ ]
+
+
+def make_migration_app(extra_config=None, model_modules=()):
+ """Return a module-scoped pytest fixture that provides a minimal Flask app context.
+
+ The app context is all alembic's env.py needs: it reads SQLALCHEMY_DATABASE_URI
+ from current_app.config and db metadata from Flask-Migrate's extension.
+
+ model_modules: sequence of dotted module paths to import when the fixture runs,
+ e.g. ["mergin.sync.models", "src.workspace.models"]. Importing here (inside the
+ fixture) rather than at test-module level avoids db.metadata cross-contamination
+ when multiple migration test files are collected in the same pytest session: models
+ are only added to db.metadata when the fixture first executes, not at collection time.
+
+ extra_config: optional dict of additional config values to set on the app.
+ Use this when a migration reads from current_app.config for non-DB settings.
+ """
+
+ @pytest.fixture(scope="module")
+ def migration_app(migration_engine):
+ import importlib
+
+ from ..app import create_simple_app
+
+ for module_path in model_modules:
+ importlib.import_module(module_path)
+
+ app = create_simple_app()
+ app.config["SQLALCHEMY_DATABASE_URI"] = migration_engine.url.render_as_string(
+ hide_password=False
+ )
+ if extra_config:
+ app.config.update(extra_config)
+ ctx = app.app_context()
+ ctx.push()
+ try:
+ yield app
+ finally:
+ ctx.pop()
+
+ return migration_app
+
+
+def make_migration_engine(test_db_name, pre_migration_sql=()):
+ """Return a module-scoped pytest fixture that creates and tears down a migration test DB.
+
+ pre_migration_sql: optional sequence of SQL statements executed once after the DB
+ is created but before any migration runs (e.g. CREATE SCHEMA, CREATE EXTENSION).
+ """
+
+ @pytest.fixture(scope="module")
+ def migration_engine():
+ from ..config import Configuration
+
+ base_url = make_url(Configuration.SQLALCHEMY_DATABASE_URI)
+ admin_url = base_url.set(database="postgres")
+ test_db_url = base_url.set(database=test_db_name)
+
+ admin_engine = create_engine(admin_url, isolation_level="AUTOCOMMIT")
+ with admin_engine.connect() as conn:
+ conn.execute(text(f"DROP DATABASE IF EXISTS {test_db_name}"))
+ conn.execute(text(f"CREATE DATABASE {test_db_name}"))
+ admin_engine.dispose()
+
+ engine = create_engine(test_db_url)
+ if pre_migration_sql:
+ with engine.connect() as conn:
+ for sql in pre_migration_sql:
+ conn.execute(text(sql))
+ conn.commit()
+
+ yield engine
+ engine.dispose()
+
+ admin_engine = create_engine(admin_url, isolation_level="AUTOCOMMIT")
+ with admin_engine.connect() as conn:
+ conn.execute(text(f"DROP DATABASE IF EXISTS {test_db_name}"))
+ admin_engine.dispose()
+
+ return migration_engine
+
+
+def run_migration_lifecycle(
+ engine, migrations_dir, revisions, metadata, downgrade_targets, include_schemas=()
+):
+ """Run the three-phase migration lifecycle used by all migration test suites.
+
+ Phase 1 — upgrade one revision at a time, asserting alembic_version after each.
+ Works for both single-head and multi-head chains: the applied-versions
+ set is checked with `in` so interleaved branch revisions pass correctly.
+ Phase 2 — structural schema consistency check between ORM metadata and the migrated DB.
+ Phase 3 — downgrade to each target in downgrade_targets (str or list[str]),
+ asserting the final alembic_version set matches exactly.
+ """
+ assert (
+ revisions
+ ), "ordered_revisions returned an empty list — check migrations_dir and version_locations"
+ for rev in revisions:
+ upgrade(directory=migrations_dir, revision=rev)
+ with engine.connect() as conn:
+ applied = {
+ row[0]
+ for row in conn.execute(
+ text("SELECT version_num FROM alembic_version")
+ ).fetchall()
+ }
+ assert (
+ rev in applied
+ ), f"Migration {rev} did not apply correctly: alembic_version is {applied!r}"
+
+ assert_schema_consistent(engine, metadata, include_schemas)
+
+ if isinstance(downgrade_targets, str):
+ downgrade_targets = [downgrade_targets]
+ for target in downgrade_targets:
+ downgrade(directory=migrations_dir, revision=target)
+ with engine.connect() as conn:
+ final = {
+ row[0]
+ for row in conn.execute(
+ text("SELECT version_num FROM alembic_version")
+ ).fetchall()
+ }
+ assert final == set(
+ downgrade_targets
+ ), f"Unexpected state after downgrade: {final!r}"
diff --git a/server/migrations/community/07f2185e2428_case_insensitive_unique_idx.py b/server/migrations/community/07f2185e2428_case_insensitive_unique_idx.py
index 8c966a9c..985c306a 100644
--- a/server/migrations/community/07f2185e2428_case_insensitive_unique_idx.py
+++ b/server/migrations/community/07f2185e2428_case_insensitive_unique_idx.py
@@ -20,18 +20,26 @@
def upgrade():
conn = op.get_bind()
conn.execute(
- "CREATE UNIQUE INDEX ix_user_username ON public.user (LOWER(username));"
+ sa.text(
+ "CREATE UNIQUE INDEX ix_user_username ON public.user (LOWER(username));"
+ )
)
- conn.execute("CREATE UNIQUE INDEX ix_user_email ON public.user (LOWER(email));")
- conn.execute("ALTER TABLE public.user DROP CONSTRAINT uq_user_email;")
- conn.execute("ALTER TABLE public.user DROP CONSTRAINT uq_user_username;")
+ conn.execute(
+ sa.text("CREATE UNIQUE INDEX ix_user_email ON public.user (LOWER(email));")
+ )
+ conn.execute(sa.text("ALTER TABLE public.user DROP CONSTRAINT uq_user_email;"))
+ conn.execute(sa.text("ALTER TABLE public.user DROP CONSTRAINT uq_user_username;"))
def downgrade():
conn = op.get_bind()
- conn.execute("DROP INDEX IF EXISTS ix_user_username;")
- conn.execute("DROP INDEX IF EXISTS ix_user_email;")
- conn.execute("ALTER TABLE public.user ADD CONSTRAINT uq_user_email UNIQUE (email);")
+ conn.execute(sa.text("DROP INDEX IF EXISTS ix_user_username;"))
+ conn.execute(sa.text("DROP INDEX IF EXISTS ix_user_email;"))
+ conn.execute(
+ sa.text("ALTER TABLE public.user ADD CONSTRAINT uq_user_email UNIQUE (email);")
+ )
conn.execute(
- "ALTER TABLE public.user ADD CONSTRAINT uq_user_username UNIQUE (username);"
+ sa.text(
+ "ALTER TABLE public.user ADD CONSTRAINT uq_user_username UNIQUE (username);"
+ )
)
diff --git a/server/migrations/community/3daefa84ce67_add_deployment_info.py b/server/migrations/community/3daefa84ce67_add_deployment_info.py
index 6b7e3f64..4014ecc8 100644
--- a/server/migrations/community/3daefa84ce67_add_deployment_info.py
+++ b/server/migrations/community/3daefa84ce67_add_deployment_info.py
@@ -34,7 +34,9 @@ def upgrade():
uuid.UUID(os.getenv("SERVICE_ID")) if os.getenv("SERVICE_ID") else uuid.uuid4()
)
conn = op.get_bind()
- conn.execute(f"INSERT INTO mergin_info VALUES ('{key}')")
+ conn.execute(
+ sa.text("INSERT INTO mergin_info VALUES (:service_id)"), {"service_id": key}
+ )
def downgrade():
diff --git a/server/migrations/community/c13819c566e7_migrate_away_from_jsonb_columns.py b/server/migrations/community/c13819c566e7_migrate_away_from_jsonb_columns.py
index 81bbddf3..55acc9d5 100644
--- a/server/migrations/community/c13819c566e7_migrate_away_from_jsonb_columns.py
+++ b/server/migrations/community/c13819c566e7_migrate_away_from_jsonb_columns.py
@@ -226,7 +226,7 @@ def data_downgrade():
WHERE pv.name = 1
)
UPDATE project_version pv
- SET files = first_pushes.files
+ SET files = first_pushes.file
FROM first_pushes
WHERE first_pushes.version_id = pv.id;
"""
diff --git a/server/migrations/community/dbd428cda965_migrate_to_jsonb.py b/server/migrations/community/dbd428cda965_migrate_to_jsonb.py
index d77b540f..62248d5a 100644
--- a/server/migrations/community/dbd428cda965_migrate_to_jsonb.py
+++ b/server/migrations/community/dbd428cda965_migrate_to_jsonb.py
@@ -10,6 +10,7 @@
"""
from alembic import op
+from sqlalchemy import text
# revision identifiers, used by Alembic.
revision = "dbd428cda965"
@@ -21,28 +22,40 @@
def upgrade():
conn = op.get_bind()
conn.execute(
- "ALTER TABLE project_version ALTER COLUMN files SET DATA TYPE jsonb USING files::jsonb;"
+ text(
+ "ALTER TABLE project_version ALTER COLUMN files SET DATA TYPE jsonb USING files::jsonb;"
+ )
)
conn.execute(
- "ALTER TABLE project_version ALTER COLUMN changes SET DATA TYPE jsonb USING changes::jsonb;"
+ text(
+ "ALTER TABLE project_version ALTER COLUMN changes SET DATA TYPE jsonb USING changes::jsonb;"
+ )
)
conn.execute(
- "ALTER TABLE project ALTER COLUMN files SET DATA TYPE jsonb USING files::jsonb;"
+ text(
+ "ALTER TABLE project ALTER COLUMN files SET DATA TYPE jsonb USING files::jsonb;"
+ )
)
conn.execute(
- "CREATE INDEX ix_project_version_files_gin ON project_version USING gin (files);"
+ text(
+ "CREATE INDEX ix_project_version_files_gin ON project_version USING gin (files);"
+ )
)
conn.execute(
- "CREATE INDEX ix_project_version_changes_gin ON project_version USING gin (changes);"
+ text(
+ "CREATE INDEX ix_project_version_changes_gin ON project_version USING gin (changes);"
+ )
+ )
+ conn.execute(
+ text("CREATE INDEX ix_project_files_gin ON project USING gin (files);")
)
- conn.execute("CREATE INDEX ix_project_files_gin ON project USING gin (files);")
def downgrade():
conn = op.get_bind()
- conn.execute("DROP INDEX IF EXISTS ix_project_version_files_gin;")
- conn.execute("DROP INDEX IF EXISTS ix_project_version_changes_gin;")
- conn.execute("DROP INDEX IF EXISTS ix_project_files_gin;")
- conn.execute("ALTER TABLE project_version ALTER COLUMN files TYPE json;")
- conn.execute("ALTER TABLE project_version ALTER COLUMN changes TYPE json;")
- conn.execute("ALTER TABLE project ALTER COLUMN files TYPE json;")
+ conn.execute(text("DROP INDEX IF EXISTS ix_project_version_files_gin;"))
+ conn.execute(text("DROP INDEX IF EXISTS ix_project_version_changes_gin;"))
+ conn.execute(text("DROP INDEX IF EXISTS ix_project_files_gin;"))
+ conn.execute(text("ALTER TABLE project_version ALTER COLUMN files TYPE json;"))
+ conn.execute(text("ALTER TABLE project_version ALTER COLUMN changes TYPE json;"))
+ conn.execute(text("ALTER TABLE project ALTER COLUMN files TYPE json;"))
From 5765de4c860032c2bc867d96b563a53877f974f9 Mon Sep 17 00:00:00 2001
From: Richard Kello
Date: Mon, 1 Jun 2026 09:47:58 +0200
Subject: [PATCH 11/16] Add configurable confirm dialog logo and update Project
Settings
---
.../dialog/components/ConfirmDialog.vue | 44 ++++++++++++++-----
.../packages/lib/src/modules/dialog/types.ts | 4 ++
.../lib/src/modules/project/routes.ts | 2 +-
.../project/views/ProjectViewTemplate.vue | 2 +-
4 files changed, 38 insertions(+), 14 deletions(-)
diff --git a/web-app/packages/lib/src/modules/dialog/components/ConfirmDialog.vue b/web-app/packages/lib/src/modules/dialog/components/ConfirmDialog.vue
index 634cfb47..087af8d8 100644
--- a/web-app/packages/lib/src/modules/dialog/components/ConfirmDialog.vue
+++ b/web-app/packages/lib/src/modules/dialog/components/ConfirmDialog.vue
@@ -6,17 +6,7 @@ SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-MerginMaps-Commercial
-

-

-

+
{{ text }}
{{ description }}
{{ hint }}
@@ -73,13 +63,18 @@ import { ref, computed, defineEmits, withDefaults } from 'vue'
import { ConfirmDialogProps } from '../types'
+import negativeIcon from '@/assets/negative.svg'
+import neutralIcon from '@/assets/neutral.svg'
+import trashIcon from '@/assets/trash.svg'
+import warningIcon from '@/assets/warning-dialog.svg'
import TipMessage from '@/common/components/TipMessage.vue'
import { useDialogStore } from '@/modules/dialog/store'
const props = withDefaults(defineProps
(), {
confirmText: 'Ok',
cancelText: 'Cancel',
- severity: 'primary'
+ severity: 'primary',
+ logoVariant: 'auto'
})
const confirmValue = ref('')
@@ -91,6 +86,31 @@ const isConfirmed = computed(() => {
: true
})
+const logoSrc = computed(() => {
+ const variant =
+ props.logoVariant === 'auto'
+ ? props.severity === 'danger'
+ ? 'danger'
+ : props.severity === 'warning'
+ ? 'warning'
+ : 'primary'
+ : props.logoVariant
+
+ if (variant === 'danger') {
+ return trashIcon
+ }
+
+ if (variant === 'warning') {
+ return warningIcon
+ }
+
+ if (variant === 'negative') {
+ return negativeIcon
+ }
+
+ return neutralIcon
+})
+
const { close } = useDialogStore()
function confirm() {
diff --git a/web-app/packages/lib/src/modules/dialog/types.ts b/web-app/packages/lib/src/modules/dialog/types.ts
index 133bce2e..49157d5b 100644
--- a/web-app/packages/lib/src/modules/dialog/types.ts
+++ b/web-app/packages/lib/src/modules/dialog/types.ts
@@ -9,6 +9,10 @@ import { TipMessageProps } from '@/common'
export interface ConfirmDialogProps {
text: string
severity?: 'primary' | 'danger' | 'warning'
+ /**
+ * Optional logo override. When omitted, logo follows current severity behavior.
+ */
+ logoVariant?: 'auto' | 'primary' | 'danger' | 'warning' | 'negative'
confirmText?: string
cancelText?: string
description?: string
diff --git a/web-app/packages/lib/src/modules/project/routes.ts b/web-app/packages/lib/src/modules/project/routes.ts
index b4fbc0b6..684cdc3c 100644
--- a/web-app/packages/lib/src/modules/project/routes.ts
+++ b/web-app/packages/lib/src/modules/project/routes.ts
@@ -59,7 +59,7 @@ export const getProjectTitle = (
query.file_path || 'Files',
route.params.projectName as string
],
- [ProjectRouteName.ProjectSettings]: ['Settings', projectName],
+ [ProjectRouteName.ProjectSettings]: ['Settings & API', projectName],
[ProjectRouteName.ProjectHistory]: [
query.version_id || 'History',
projectName
diff --git a/web-app/packages/lib/src/modules/project/views/ProjectViewTemplate.vue b/web-app/packages/lib/src/modules/project/views/ProjectViewTemplate.vue
index e8ea5b63..027b32f8 100644
--- a/web-app/packages/lib/src/modules/project/views/ProjectViewTemplate.vue
+++ b/web-app/packages/lib/src/modules/project/views/ProjectViewTemplate.vue
@@ -208,7 +208,7 @@ export default defineComponent({
})
tabs.push({
route: ProjectRouteName.ProjectSettings,
- header: 'Settings'
+ header: 'Settings & API'
})
}
}
From c70625af588ec70eaf162c0e843c9911e7025e87 Mon Sep 17 00:00:00 2001
From: Richard Kello
Date: Mon, 1 Jun 2026 10:08:22 +0200
Subject: [PATCH 12/16] Replace boolean value
---
.../admin-lib/src/modules/admin/components/AccountsTable.vue | 4 ++--
1 file changed, 2 insertions(+), 2 deletions(-)
diff --git a/web-app/packages/admin-lib/src/modules/admin/components/AccountsTable.vue b/web-app/packages/admin-lib/src/modules/admin/components/AccountsTable.vue
index aee0ce4d..08fb807e 100644
--- a/web-app/packages/admin-lib/src/modules/admin/components/AccountsTable.vue
+++ b/web-app/packages/admin-lib/src/modules/admin/components/AccountsTable.vue
@@ -63,7 +63,7 @@
class="dt-row-link"
:class="header.class"
>
- {{
+ {{
fieldValue(data, header.field) ? 'Active' : 'Inactive'
}}
{{
@@ -116,7 +116,7 @@ const headers: TableDataHeader[] = [
},
{ field: 'email', header: 'Email', sortable: true, linked: true },
{ field: 'profile.name', header: 'Full name', linked: true },
- { field: 'active', header: 'Active', linked: true, type: 'boolean' }
+ { field: 'active', header: 'Active', linked: true }
]
export default defineComponent({
From 6b9aba40cf4c680f7a13622c8b4d6a0865f15daa Mon Sep 17 00:00:00 2001
From: Richard Kello
Date: Mon, 1 Jun 2026 11:24:23 +0200
Subject: [PATCH 13/16] Make some naming changes
---
.../lib/src/modules/project/views/ProjectViewTemplate.vue | 8 ++++++--
1 file changed, 6 insertions(+), 2 deletions(-)
diff --git a/web-app/packages/lib/src/modules/project/views/ProjectViewTemplate.vue b/web-app/packages/lib/src/modules/project/views/ProjectViewTemplate.vue
index 027b32f8..c0f517bb 100644
--- a/web-app/packages/lib/src/modules/project/views/ProjectViewTemplate.vue
+++ b/web-app/packages/lib/src/modules/project/views/ProjectViewTemplate.vue
@@ -164,7 +164,11 @@ export default defineComponent({
type: Boolean,
default: false
},
- mapRoute: String
+ mapRoute: String,
+ settingsTabHeader: {
+ type: String,
+ default: 'Settings'
+ }
},
data() {
return {
@@ -208,7 +212,7 @@ export default defineComponent({
})
tabs.push({
route: ProjectRouteName.ProjectSettings,
- header: 'Settings & API'
+ header: this.settingsTabHeader
})
}
}
From f18e219dbce07a69c23c27b537b526bd9580deeb Mon Sep 17 00:00:00 2001
From: Herman Snevajs
Date: Fri, 5 Jun 2026 09:39:43 +0200
Subject: [PATCH 14/16] Add classes for invitation action icons
---
.../themes/mm-theme-light/_extensions.scss | 19 +++++++++++++++++++
.../components/ProjectShareDialogTemplate.vue | 2 +-
2 files changed, 20 insertions(+), 1 deletion(-)
diff --git a/web-app/packages/lib/src/assets/sass/themes/mm-theme-light/_extensions.scss b/web-app/packages/lib/src/assets/sass/themes/mm-theme-light/_extensions.scss
index 156473c4..2fb161a3 100644
--- a/web-app/packages/lib/src/assets/sass/themes/mm-theme-light/_extensions.scss
+++ b/web-app/packages/lib/src/assets/sass/themes/mm-theme-light/_extensions.scss
@@ -118,6 +118,25 @@ img {
overflow-wrap: anywhere;
}
+// Icon-only action button used in list rows (e.g. resend / trash on invitation rows)
+.p-button.icon-action-btn {
+ color: map-get($colors, 'forest');
+
+ &:not(:disabled):hover {
+ background: map-get($colors, 'light-green');
+ color: map-get($colors, 'forest');
+ }
+
+ &.icon-action-btn--danger {
+ color: map-get($colors, 'grape');
+
+ &:not(:disabled):hover {
+ background: map-get($colors, 'negative-light');
+ color: map-get($colors, 'grape');
+ }
+ }
+}
+
// Color of error messages in inputs ...
.p-error {
color: map-get($map: $colors, $key: grape);
diff --git a/web-app/packages/lib/src/modules/project/components/ProjectShareDialogTemplate.vue b/web-app/packages/lib/src/modules/project/components/ProjectShareDialogTemplate.vue
index c25bc181..c66fd4a0 100644
--- a/web-app/packages/lib/src/modules/project/components/ProjectShareDialogTemplate.vue
+++ b/web-app/packages/lib/src/modules/project/components/ProjectShareDialogTemplate.vue
@@ -31,7 +31,7 @@ SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-MerginMaps-Commercial
class="underline"
>Learn more about permission system.
+ >
Date: Fri, 12 Jun 2026 09:20:12 +0200
Subject: [PATCH 15/16] Suppress focus halo on icon action buttons after mouse
click
QA reported the resend / trash buttons on invitation rows keep the
focus halo after a mouse click. Add a :focus:not(:focus-visible) rule
to .icon-action-btn so the ring is dropped on mouse interaction but
preserved for keyboard navigation (accessibility).
---
.../src/assets/sass/themes/mm-theme-light/_extensions.scss | 5 +++++
1 file changed, 5 insertions(+)
diff --git a/web-app/packages/lib/src/assets/sass/themes/mm-theme-light/_extensions.scss b/web-app/packages/lib/src/assets/sass/themes/mm-theme-light/_extensions.scss
index 2fb161a3..6da85d8c 100644
--- a/web-app/packages/lib/src/assets/sass/themes/mm-theme-light/_extensions.scss
+++ b/web-app/packages/lib/src/assets/sass/themes/mm-theme-light/_extensions.scss
@@ -122,6 +122,11 @@ img {
.p-button.icon-action-btn {
color: map-get($colors, 'forest');
+ // Suppress the focus halo after a mouse click
+ &:focus:not(:focus-visible) {
+ box-shadow: none;
+ }
+
&:not(:disabled):hover {
background: map-get($colors, 'light-green');
color: map-get($colors, 'forest');
From 3ff5dc25dcaa8d4fe993917e4b59d8c785f27e59 Mon Sep 17 00:00:00 2001
From: Herman Snevajs
Date: Thu, 18 Jun 2026 13:33:53 +0200
Subject: [PATCH 16/16] Apply focus halo suppression to all PrimeVue buttons
The :focus:not(:focus-visible) rule was previously scoped only to
.icon-action-btn, so it only worked on the icon-only resend/trash
buttons in the workspace Members and project Collaborators tables.
Other invitation-related buttons (the labeled Resend pill on the
dashboard, the X on the dashboard, the labeled buttons in the
invitation sidebar) kept the lingering focus halo after a mouse click.
Move the rule out of .icon-action-btn and apply it globally to
.p-button. The :focus-visible exclusion still preserves the focus ring
for keyboard navigation, so accessibility is unaffected.
---
.../sass/themes/mm-theme-light/_extensions.scss | 11 ++++++-----
1 file changed, 6 insertions(+), 5 deletions(-)
diff --git a/web-app/packages/lib/src/assets/sass/themes/mm-theme-light/_extensions.scss b/web-app/packages/lib/src/assets/sass/themes/mm-theme-light/_extensions.scss
index 6da85d8c..de9cf422 100644
--- a/web-app/packages/lib/src/assets/sass/themes/mm-theme-light/_extensions.scss
+++ b/web-app/packages/lib/src/assets/sass/themes/mm-theme-light/_extensions.scss
@@ -118,15 +118,16 @@ img {
overflow-wrap: anywhere;
}
+// Suppress the focus halo after a mouse click on any PrimeVue button while
+// keeping the ring for keyboard navigation (accessibility).
+.p-button:focus:not(:focus-visible) {
+ box-shadow: none;
+}
+
// Icon-only action button used in list rows (e.g. resend / trash on invitation rows)
.p-button.icon-action-btn {
color: map-get($colors, 'forest');
- // Suppress the focus halo after a mouse click
- &:focus:not(:focus-visible) {
- box-shadow: none;
- }
-
&:not(:disabled):hover {
background: map-get($colors, 'light-green');
color: map-get($colors, 'forest');