Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion server/mergin/celery.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,6 @@
from .config import Configuration
from .app import mail


# create on flask app independent object
# we need this for defining tasks, and celery is then configured in run_celery.py
celery = Celery(
Expand Down Expand Up @@ -68,6 +67,7 @@ def __call__(self, *args, **kwargs):
celery.conf.update(app.config)
celery.conf.update(
task_acks_late=Configuration.CELERY_ACKS_LATE,
task_send_sent_event=Configuration.CELERY_SEND_TASK_SENT_EVENT,
worker_concurrency=Configuration.CELERYD_CONCURRENCY,
worker_prefetch_multiplier=Configuration.CELERYD_PREFETCH_MULTIPLIER,
)
Expand Down
5 changes: 5 additions & 0 deletions server/mergin/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,11 @@ class Configuration(object):
"CELERY_RESULT_BACKEND_TRANSPORT_OPTIONS", default="{}", cast=eval
)
CELERY_ACKS_LATE = config("CELERY_ACKS_LATE", default=False, cast=bool)
# send a task-sent event when a task is published so that monitoring tools
# (e.g. celery-exporter) can report the queue name, including for failed tasks
CELERY_SEND_TASK_SENT_EVENT = config(
"CELERY_SEND_TASK_SENT_EVENT", default=False, cast=bool
)
CELERYD_CONCURRENCY = config("CELERYD_CONCURRENCY", default=1, cast=int)
CELERYD_PREFETCH_MULTIPLIER = config(
"CELERYD_PREFETCH_MULTIPLIER", default=4, cast=int
Expand Down
9 changes: 6 additions & 3 deletions web-app/packages/admin-lib/src/modules/admin/routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,15 +21,18 @@ export enum AdminRoutes {
Login = 'login'
}

export const getAdminTitle = (route: RouteLocationNormalizedLoaded) => {
export const getAdminTitle = (
route: RouteLocationNormalizedLoaded,
config?: { accountEmail?: string }
) => {
const params = route.params as AdminRouteParams
const titles: Record<AdminRoutes, string | string[]> = {
[AdminRoutes.Login]: ['Sign in', 'Mergin Maps Admin Panel'],
[AdminRoutes.ACCOUNTS]: 'Accounts',
[AdminRoutes.ACCOUNT]: 'Account details',
[AdminRoutes.ACCOUNT]: [config?.accountEmail || 'Account details'],
[AdminRoutes.OVERVIEW]: 'Admin overview',
[AdminRoutes.PROJECTS]: 'Projects',
[AdminRoutes.PROJECT]: ['Details', params.projectName],
[AdminRoutes.PROJECT]: [params.projectName],
[AdminRoutes.SETTINGS]: 'Settings',
[AdminRoutes.ProjectTree]: ['Files', params.projectName],
[AdminRoutes.ProjectHistory]: ['History', params.projectName],
Expand Down
129 changes: 100 additions & 29 deletions web-app/packages/admin-lib/src/modules/admin/views/AccountDetailView.vue
Original file line number Diff line number Diff line change
Expand Up @@ -10,26 +10,30 @@
<template v-if="user">
<app-container>
<app-section class="p-4">
<div
class="flex flex-column align-items-center row-gap-3 text-center"
>
<PAvatar
:label="$filters.getAvatar(user?.email, profile?.name)"
size="xlarge"
shape="circle"
:pt="{
root: {
class: 'font-semibold'
}
}"
/>
<h3 class="headline-h2" data-cy="profile-name">
{{
profile?.name
? `${profile.name} (${user?.username})`
: user.username
}}
</h3>
<div class="flex flex-column row-gap-3">
<div class="flex align-items-center gap-2">
<PAvatar
:label="$filters.getAvatar(user?.email, profile?.name)"
shape="circle"
:pt="{
root: {
class: 'font-semibold flex-shrink-0',
style: {
width: '2.25rem',
height: '2.25rem',
fontSize: '1rem'
}
}
}"
/>
<h2 class="headline-h2" data-cy="profile-name">
{{
profile?.name
? `${profile.name} (${user?.username})`
: user.username
}}
</h2>
</div>
<p
class="m-0 paragraph-p6 overflow-wrap-anywhere"
data-cy="profile-email"
Expand All @@ -45,18 +49,32 @@
{{ user?.email }}
</p>
<dl
class="profile-view-detail-list grid grid-nogutter paragraph-p5"
class="project-view-detail-list paragraph-p5 flex flex-column gap-3"
>
<div
class="col-6 flex flex-column align-items-start text-left flex-wrap"
>
<dt class="paragraph-p6 opacity-80 mb-2">Last signed in</dt>
<div>
<dt class="paragraph-p6 opacity-80">User ID</dt>
<dd class="font-semibold" data-cy="profile-id">
<button
type="button"
class="id-copy-pill"
v-tooltip.top="'Copy to clipboard'"
:aria-label="`Copy user ID ${user?.id}`"
:disabled="!user?.id"
@click="copyId"
>
<span>{{ user?.id }}</span>
<i class="ti ti-copy"></i>
</button>
</dd>
</div>
<div>
<dt class="paragraph-p6 opacity-80">Last signed in</dt>
<dd class="font-semibold" data-cy="profile-last-signed-in">
{{ $filters.date(user.last_signed_in) || '-' }}
</dd>
</div>
<div class="col-6 flex flex-column align-items-end">
<dt class="paragraph-p6 opacity-80 mb-2">Registered</dt>
<div>
<dt class="paragraph-p6 opacity-80">Registered</dt>
<dd class="font-semibold" data-cy="profile-registered">
{{ $filters.date(user?.registration_date) }}
</dd>
Expand Down Expand Up @@ -138,13 +156,15 @@ import {
ConfirmDialogProps,
AppSettings,
AppSettingsItemConfig,
useCopyToClipboard,
useInstanceStore,
useUserStore
} from '@mergin/lib'
import { computed, watch } from 'vue'
import { computed, watch, watchEffect } from 'vue'
import { useRoute } from 'vue-router'

import AdminLayout from '@/modules/admin/components/AdminLayout.vue'
import { getAdminTitle } from '@/modules/admin/routes'
import { useAdminStore } from '@/modules/admin/store'

const route = useRoute()
Expand Down Expand Up @@ -183,6 +203,9 @@ const settingsItems = computed<AppSettingsItemConfig[]>(() => [
}
])

const { copy } = useCopyToClipboard()
const copyId = () => copy(user.value?.id, 'User ID')

const user = computed(() => adminStore.user)
const profile = computed(() => adminStore.user?.profile)
const routeUsername = computed(() => route?.params?.username)
Expand All @@ -192,6 +215,16 @@ const fetchProfile = (username: string) => {
adminStore.fetchUserByName({ username })
}

watchEffect(() => {
// We need to handle the title separately, as the account's email is not
// available in the route and only loads after the profile is fetched
if (user.value?.email) {
document.title = getAdminTitle(route, {
accountEmail: user.value.email
})?.[0]
}
})

watch(
routeUsername,
(username) => {
Expand Down Expand Up @@ -300,8 +333,46 @@ const switchAdminAccess = async () => {
</script>

<style lang="scss" scoped>
.profile-view-detail-list {
h2 {
color: var(--text-color);
}

.project-view-detail-list {
max-width: 640px;
width: 100%;
}

.id-copy-pill {
display: inline-flex;
align-items: center;
gap: 0.375rem;
margin: -0.25rem -0.5rem;
padding: 0.25rem 0.5rem;
border: none;
border-radius: 6px;
background: transparent;
color: var(--text-color);
font: inherit;
font-weight: 600;
cursor: pointer;
transition: background-color 0.15s ease;

&:hover {
background: var(--surface-hover);
}

&:disabled {
cursor: default;
opacity: 0.6;

&:hover {
background: transparent;
}
}

i {
font-size: 0.95rem;
color: inherit;
}
}
</style>
66 changes: 66 additions & 0 deletions web-app/packages/admin-lib/src/modules/admin/views/ProjectView.vue
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,8 @@
<h2 class="headline-h2" data-cy="project-name">
<template v-if="showWorkspaceName"
><router-link
class="workspace-name-link"
v-tooltip.top="'Open workspace detail'"
:to="{
name: 'adminWorkspace',
params: { id: project?.workspace_id }
Expand All @@ -46,6 +48,22 @@
<dl
class="project-view-detail-list paragraph-p5 flex flex-column gap-3"
>
<div>
<dt class="paragraph-p6 opacity-80">Project ID</dt>
<dd class="font-semibold" data-cy="project-id">
<button
type="button"
class="id-copy-pill"
v-tooltip.top="'Copy to clipboard'"
:aria-label="`Copy project ID ${project?.id}`"
:disabled="!project?.id"
@click="copyId"
>
<span>{{ project?.id }}</span>
<i class="ti ti-copy"></i>
</button>
</dd>
</div>
<div>
<dt class="paragraph-p6 opacity-80">Created</dt>
<dd class="font-semibold" data-cy="project-owner">
Expand Down Expand Up @@ -110,6 +128,7 @@
import {
AppSection,
AppContainer,
useCopyToClipboard,
useProjectStore,
ProjectApi,
DownloadProgress,
Expand Down Expand Up @@ -153,6 +172,9 @@ const tabs = computed(() => {
return tabs
})

const { copy } = useCopyToClipboard()
const copyId = () => copy(project.value?.id, 'Project ID')

const project = computed(() => projectStore.project)
const routeProjectName = computed(() => route?.params?.projectName as string)
const routeWorkspaceName = computed(() => route?.params?.namespace as string)
Expand Down Expand Up @@ -214,8 +236,52 @@ function openDashboard() {
</script>

<style lang="scss" scoped>
h2 {
color: var(--text-color);
}

.project-view-detail-list {
max-width: 640px;
width: 100%;
}

.id-copy-pill {
display: inline-flex;
align-items: center;
gap: 0.375rem;
margin: -0.25rem -0.5rem;
padding: 0.25rem 0.5rem;
border: none;
border-radius: 6px;
background: transparent;
color: var(--text-color);
font: inherit;
font-weight: 600;
cursor: pointer;
transition: background-color 0.15s ease;

&:hover {
background: var(--surface-hover);
}

&:disabled {
cursor: default;
opacity: 0.6;

&:hover {
background: transparent;
}
}

i {
font-size: 0.95rem;
color: inherit;
}
}

.workspace-name-link {
text-decoration: underline dotted;
text-underline-offset: 3px;
color: inherit;
}
</style>
1 change: 1 addition & 0 deletions web-app/packages/lib/src/common/composables/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,5 +3,6 @@
// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-MerginMaps-Commercial

export { default as useRouterTitle } from './use_router_title'
export { default as useCopyToClipboard } from './useCopyToClipboard'
export { useDataTableSearch } from './useDataTableSearch'
export type { DataTableSearchOptions } from './useDataTableSearch'
30 changes: 30 additions & 0 deletions web-app/packages/lib/src/common/composables/useCopyToClipboard.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
// Copyright (C) Lutra Consulting Limited
//
// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-MerginMaps-Commercial

import { useNotificationStore } from '@/modules/notification'

const useCopyToClipboard = () => {
const notificationStore = useNotificationStore()

const copy = async (
value: string | number | null | undefined,
label = 'Value'
) => {
if (value === null || value === undefined || value === '') {
return
}
try {
await navigator.clipboard.writeText(String(value))
notificationStore.show({ text: `${label} copied to clipboard` })
} catch {
notificationStore.error({
text: `Failed to copy ${label.toLowerCase()} to clipboard`
})
}
}

return { copy }
}

export default useCopyToClipboard
Loading