Browse Source

Merge pull request #8836 from nocodb/nc-feat/audit-logs

Nc Feat: Audit logs v1
pull/8845/head
Ramesh Mane 5 months ago committed by GitHub
parent
commit
3b346ce090
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
  1. 6
      packages/nc-gui/assets/nc-icons/chevron-up-down.svg
  2. 9
      packages/nc-gui/assets/nc-icons/refresh.svg
  3. 1
      packages/nc-gui/components/cell/DatePicker.vue
  4. 43
      packages/nc-gui/components/dashboard/TreeView/ProjectNode.vue
  5. 14
      packages/nc-gui/components/dlg/ProjectAudit.vue
  6. 34
      packages/nc-gui/components/nc/Pagination.vue
  7. 693
      packages/nc-gui/components/workspace/AuditLogs.vue
  8. 21
      packages/nc-gui/components/workspace/View.vue
  9. 7
      packages/nc-gui/lang/en.json
  10. 2
      packages/nc-gui/lib/acl.ts
  11. 8
      packages/nc-gui/lib/enums.ts
  12. 31
      packages/nc-gui/lib/types.ts
  13. 16
      packages/nc-gui/pages/account/index.vue
  14. 3
      packages/nc-gui/pages/account/index/[page].vue
  15. 63
      packages/nc-gui/store/workspace.ts
  16. 4
      packages/nc-gui/utils/iconUtils.ts
  17. 49
      packages/nocodb-sdk/src/lib/globals.ts
  18. 20
      packages/nocodb/src/controllers/audits.controller.ts
  19. 75
      packages/nocodb/src/models/Audit.ts
  20. 4
      packages/nocodb/src/models/View.ts
  21. 124
      packages/nocodb/src/schema/swagger-v2.json
  22. 124
      packages/nocodb/src/schema/swagger.json
  23. 18
      packages/nocodb/src/services/audits.service.ts
  24. 5
      tests/playwright/pages/Dashboard/common/Cell/index.ts
  25. 2
      tests/playwright/tests/db/features/verticalFillHandle.spec.ts

6
packages/nc-gui/assets/nc-icons/chevron-up-down.svg

@ -0,0 +1,6 @@
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16" fill="none" class="flex-none">
<path d="M12 6L8 2L4 6" stroke="currentColor" stroke-width="1.33333" stroke-linecap="round" stroke-linejoin="round"
class="up" />
<path d=" M4 10L8 14L12 10" stroke="currentColor" stroke-width="1.33333" stroke-linecap="round"
stroke-linejoin="round" class="down" />
</svg>

After

Width:  |  Height:  |  Size: 410 B

9
packages/nc-gui/assets/nc-icons/refresh.svg

@ -0,0 +1,9 @@
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16" fill="none">
<path d="M0.666504 13.333V9.33301H4.6665" stroke="currentColor" stroke-width="1.33333" stroke-linecap="round"
stroke-linejoin="round" />
<path d="M15.3335 2.66699V6.66699H11.3335" stroke="currentColor" stroke-width="1.33333" stroke-linecap="round"
stroke-linejoin="round" />
<path
d="M2.33984 6.00038C2.67795 5.0449 3.25259 4.19064 4.01015 3.51732C4.7677 2.844 5.68348 2.37355 6.67203 2.14988C7.66058 1.92621 8.68967 1.9566 9.6633 2.23823C10.6369 2.51985 11.5233 3.04352 12.2398 3.76038L15.3332 6.66704M0.666504 9.33371L3.75984 12.2404C4.47634 12.9572 5.36275 13.4809 6.33638 13.7625C7.31 14.0441 8.3391 14.0745 9.32765 13.8509C10.3162 13.6272 11.232 13.1568 11.9895 12.4834C12.7471 11.8101 13.3217 10.9559 13.6598 10.0004"
stroke="currentColor" stroke-width="1.33333" stroke-linecap="round" stroke-linejoin="round" />
</svg>

After

Width:  |  Height:  |  Size: 965 B

1
packages/nc-gui/components/cell/DatePicker.vue

@ -342,7 +342,6 @@ function handleSelectDate(value?: dayjs.Dayjs) {
v-model:page-date="tempDate"
:is-open="isOpen"
:selected-date="localState"
:is-monday-first="false"
type="date"
size="medium"
@update:selected-date="handleSelectDate"

43
packages/nc-gui/components/dashboard/TreeView/ProjectNode.vue

@ -29,6 +29,8 @@ const { isMobileMode } = useGlobal()
const { api } = useApi()
const { auditLogsQuery, auditCurrentPage } = storeToRefs(useWorkspace())
const { createProject: _createProject, updateProject, getProjectMetaInfo, loadProject } = basesStore
const { bases } = storeToRefs(basesStore)
@ -448,6 +450,36 @@ const onTableIdCopy = async () => {
const getSource = (sourceId: string) => {
return base.value.sources?.find((s) => s.id === sourceId)
}
async function openAudit(source: SourceType) {
$e('c:project:audit')
auditCurrentPage.value = 1
auditLogsQuery.value = {
...auditLogsQuery.value,
orderBy: {
created_at: 'desc',
user: undefined,
},
}
const isOpen = ref(true)
const { close } = useDialog(resolveComponent('DlgProjectAudit'), {
'modelValue': isOpen,
'sourceId': source!.id,
'onUpdate:modelValue': () => closeDialog(),
'baseId': base.value!.id,
'bordered': true,
})
function closeDialog() {
isOpen.value = false
close(1000)
}
}
</script>
<template>
@ -581,6 +613,17 @@ const getSource = (sourceId: string) => {
</div>
</NcMenuItem>
<!-- Audit -->
<NcMenuItem
v-if="isUIAllowed('baseAuditList') && base?.sources?.[0]?.enabled"
key="audit"
data-testid="nc-sidebar-base-audit"
@click="openAudit(base?.sources?.[0])"
>
<GeneralIcon icon="audit" class="group-hover:text-black" />
{{ $t('title.audit') }}
</NcMenuItem>
<!-- Swagger: Rest APIs -->
<NcMenuItem
v-if="isUIAllowed('apiDocs')"

14
packages/nc-gui/components/dlg/ProjectAudit.vue

@ -1,15 +1,17 @@
<script lang="ts" setup>
const props = defineProps<{
workspaceId?: string
baseId: string
sourceId: string
modelValue: boolean
bordered?: boolean
}>()
const emit = defineEmits(['update:modelValue'])
const isOpen = useVModel(props, 'modelValue', emit)
const activeSourceId = computed(() => props.sourceId)
const { workspaceId, sourceId, bordered } = toRefs(props)
const { openedProject: base } = storeToRefs(useBases())
@ -45,9 +47,15 @@ onMounted(async () => {
</script>
<template>
<GeneralModal v-model:visible="isOpen" size="xl" class="!w-[70rem] !top-[5vh]">
<GeneralModal v-model:visible="isOpen" size="xl" class="!top-[5vh] lg:!max-w-[calc(100vw_-_64px)]" width="96.95rem">
<div class="p-6 h-full">
<DashboardSettingsBaseAudit v-if="!isLoading" :source-id="activeSourceId" :base-id="baseId" :show-all-columns="false" />
<WorkspaceAuditLogs
v-if="!isLoading"
:workspace-id="workspaceId"
:source-id="sourceId"
:base-id="baseId"
:bordered="bordered"
/>
</div>
</GeneralModal>
</template>

34
packages/nc-gui/components/nc/Pagination.vue

@ -1,22 +1,28 @@
<script setup lang="ts">
import NcTooltip from '~/components/nc/Tooltip.vue'
const props = defineProps<{
current: number
total: number
pageSize: number
entityName?: string
mode?: 'simple' | 'full'
prevPageTooltip?: string
nextPageTooltip?: string
firstPageTooltip?: string
lastPageTooltip?: string
showSizeChanger?: boolean
}>()
const props = withDefaults(
defineProps<{
current: number
total: number
pageSize: number
entityName?: string
mode?: 'simple' | 'full'
prevPageTooltip?: string
nextPageTooltip?: string
firstPageTooltip?: string
lastPageTooltip?: string
showSizeChanger?: boolean
useStoredPageSize?: boolean
}>(),
{
useStoredPageSize: true,
},
)
const emits = defineEmits(['update:current', 'update:pageSize'])
const { total, showSizeChanger } = toRefs(props)
const { total, showSizeChanger, useStoredPageSize } = toRefs(props)
const current = useVModel(props, 'current', emits)
@ -26,7 +32,7 @@ const { gridViewPageSize, setGridViewPageSize } = useGlobal()
const localPageSize = computed({
get: () => {
if (!showSizeChanger.value) return pageSize.value
if (!showSizeChanger.value || (showSizeChanger.value && !useStoredPageSize.value)) return pageSize.value
const storedPageSize = gridViewPageSize.value || 25

693
packages/nc-gui/components/workspace/AuditLogs.vue

@ -0,0 +1,693 @@
<script setup lang="ts">
import { Empty } from 'ant-design-vue'
import type { AuditType, UserType, WorkspaceUserType } from 'nocodb-sdk'
import { auditOperationSubTypeLabels, auditOperationTypeLabels, timeAgo } from 'nocodb-sdk'
interface Props {
workspaceId?: string
baseId?: string
sourceId?: string
bordered?: boolean
}
const props = withDefaults(defineProps<Props>(), {
bordered: true,
})
const { isUIAllowed } = useRoles()
const { $api } = useNuxtApp()
const workspaceStore = useWorkspace()
const { loadAudits: _loadAudits } = workspaceStore
const {
audits,
auditLogsQuery,
auditCurrentLimit: currentLimit,
auditCurrentPage: currentPage,
auditTotalRows: totalRows,
} = storeToRefs(workspaceStore)
const basesStore = useBases()
const { getBaseUsers, loadProjects } = basesStore
const { bases } = storeToRefs(basesStore)
const localCollaborators = ref<User[] | UserType[]>([])
const collaboratorsMap = computed<Map<string, (WorkspaceUserType & { id: string }) | User | UserType>>(() => {
const map = new Map()
localCollaborators.value?.forEach((coll) => {
if (coll?.email) {
map.set(coll.email, coll)
}
})
return map
})
const { appInfo } = useGlobal()
const isLoading = ref(false)
const isRowExpanded = ref(false)
const selectedAudit = ref<null | AuditType>(null)
const tableWrapper = ref<HTMLDivElement>()
async function loadAudits(page = currentPage.value, limit = currentLimit.value, updateCurrentPage = true) {
try {
if ((!isUIAllowed('workspaceAuditList') && !props.baseId) || (!isUIAllowed('baseAuditList') && props.baseId)) {
return
}
if (updateCurrentPage) {
currentPage.value = 1
}
isLoading.value = true
await _loadAudits(props.workspaceId, updateCurrentPage ? 1 : page, limit)
} catch {
} finally {
isLoading.value = false
}
}
const loadCollaborators = async () => {
try {
if (!auditLogsQuery.value.baseId) return
const { users } = await getBaseUsers({
baseId: auditLogsQuery.value.baseId,
})
localCollaborators.value = users
} catch (e: any) {
message.error(await extractSdkResponseErrorMsg(e))
}
}
const loadOrgUsers = async () => {
try {
const response: any = await $api.orgUsers.list()
if (!response?.list) return
localCollaborators.value = response.list as UserType[]
} catch (e: any) {
message.error(await extractSdkResponseErrorMsg(e))
}
}
const handleRowClick = (audit: AuditType) => {
selectedAudit.value = audit
isRowExpanded.value = true
}
const updateOrderBy = (field: 'created_at' | 'user') => {
if (!audits.value?.length) return
if (auditLogsQuery.value.orderBy?.[field] === 'asc') {
auditLogsQuery.value.orderBy[field] = 'desc'
} else if (auditLogsQuery.value.orderBy?.[field] === 'desc') {
auditLogsQuery.value.orderBy[field] = undefined
} else {
auditLogsQuery.value.orderBy[field] = 'asc'
}
loadAudits(undefined, undefined, false)
}
const isEditEnabled = computed(() => true)
// provide the following to override the default behavior and enable input fields like in form
provide(ActiveCellInj, ref(true))
provide(EditModeInj, readonly(isEditEnabled))
provide(IsFormInj, ref(true))
watch(
() => auditLogsQuery.value.baseId,
() => {
if (!auditLogsQuery.value.baseId) return
loadCollaborators()
},
{
immediate: true,
},
)
onMounted(async () => {
if (props.baseId) {
auditLogsQuery.value.baseId = props.baseId
} else {
auditLogsQuery.value.baseId = undefined
auditLogsQuery.value.sourceId = undefined
}
if (props.sourceId) {
auditLogsQuery.value.sourceId = props.sourceId
}
if (!props.baseId) {
await Promise.allSettled([loadProjects(), loadOrgUsers()])
}
if (appInfo.value.auditEnabled) {
await loadAudits(currentPage.value, currentLimit.value, false)
}
})
useEventListener(tableWrapper, 'scroll', () => {
const stickyHeaderCell = tableWrapper.value?.querySelector('th.cell-user')
const nonStickyHeaderFirstCell = tableWrapper.value?.querySelector('th.cell-base')
if (!stickyHeaderCell?.getBoundingClientRect().right || !nonStickyHeaderFirstCell?.getBoundingClientRect().left) {
return
}
if (nonStickyHeaderFirstCell?.getBoundingClientRect().left < stickyHeaderCell?.getBoundingClientRect().right + 180) {
tableWrapper.value?.classList.add('sticky-shadow')
} else {
tableWrapper.value?.classList.remove('sticky-shadow')
}
})
</script>
<template>
<div class="h-full flex flex-col" :class="{ 'gap-6 pb-6': !baseId, 'gap-4': baseId }">
<div v-if="!appInfo.auditEnabled" class="text-red-500">Audit logs are currently disabled by administrators.</div>
<div class="flex flex-col" :class="{ 'gap-6': !baseId, 'gap-4': baseId }">
<div class="flex flex-row items-center gap-3 justify-between">
<div
:class="{
'flex-1 max-w-[75%]': baseId,
}"
>
<h6
class="font-semibold text-gray-900 !my-0 flex items-center gap-1"
:style="{
'word-break': 'keep-all',
}"
:class="{
'text-xl': baseId,
'text-2xl': !baseId,
}"
>
<span class="keep-word min-w-[100px]"> {{ $t('title.auditLogs') }} </span>
<NcTooltip
v-if="baseId"
class="max-w-[80%] truncate"
:class="{
'!leading-7': baseId,
'!leading-8': !baseId,
}"
show-on-truncate-only
placement="bottom"
>
<template #title>
{{ bases.get(baseId)?.title }}
</template>
: {{ bases.get(baseId)?.title }}
</NcTooltip>
</h6>
</div>
<div v-if="appInfo.auditEnabled" class="flex items-center gap-3 justify-end flex-wrap">
<div class="flex items-center gap-3">
<NcButton type="text" size="small" :disabled="isLoading" @click="loadAudits()">
<!-- Refresh -->
<div class="flex items-center gap-2">
{{ $t('general.refresh') }}
<component :is="iconMap.refresh" class="h-3.5 w-3.5" :class="{ 'animate-infinite animate-spin': isLoading }" />
</div>
</NcButton>
</div>
</div>
</div>
</div>
<template v-if="appInfo.auditEnabled">
<div
class="table-container relative"
:class="{
'h-[calc(100%_-_48px)] ': baseId,
'h-[calc(100%_-_56px)]': !baseId,
'bordered': bordered,
}"
>
<div
ref="tableWrapper"
class="nc-audit-logs-table h-full max-h-[calc(100%_-_40px)] relative nc-scrollbar-thin !overflow-auto"
>
<table class="!sticky top-0 z-10">
<thead>
<tr>
<th
class="cell-user !hover:bg-gray-100 select-none cursor-pointer"
:class="{
'cursor-not-allowed': !audits?.length,
}"
@click="updateOrderBy('user')"
>
<div class="flex items-center gap-3">
<div>{{ $t('objects.user') }}</div>
<GeneralIcon
v-if="auditLogsQuery.orderBy?.user"
icon="chevronDown"
class="flex-none"
:class="{
'transform rotate-180': auditLogsQuery.orderBy?.user === 'asc',
}"
/>
<GeneralIcon v-else icon="chevronUpDown" class="flex-none" />
</div>
</th>
<th
class="cell-timestamp !hover:bg-gray-100 select-none cursor-pointer"
:class="{
'cursor-not-allowed': !audits?.length,
}"
@click="updateOrderBy('created_at')"
>
<div class="flex items-center gap-3">
<div>Time</div>
<GeneralIcon
v-if="auditLogsQuery.orderBy?.created_at"
icon="chevronDown"
class="flex-none"
:class="{
'transform rotate-180': auditLogsQuery.orderBy?.created_at === 'asc',
}"
/>
<GeneralIcon v-else icon="chevronUpDown" class="flex-none" />
</div>
</th>
<th class="cell-base">
<div>{{ $t('objects.project') }}</div>
</th>
<th class="cell-type">
<div>{{ $t('general.type') }}</div>
</th>
<th class="cell-sub-type">
<div>{{ $t('general.subType') }}</div>
</th>
<th class="cell-description">
<div>{{ $t('labels.description') }}</div>
</th>
<th class="cell-ip">
<div>{{ $t('general.ipAddress') }}</div>
</th>
</tr>
</thead>
</table>
<template v-if="audits?.length">
<table>
<tbody>
<tr
v-for="(audit, i) of audits"
:key="i"
:class="{
selected: selectedAudit?.id === audit.id && isRowExpanded,
}"
@click="handleRowClick(audit)"
>
<td class="cell-user">
<div>
<div v-if="audit.user && collaboratorsMap.get(audit.user)?.email" class="w-full flex gap-3 items-center">
<GeneralUserIcon :email="collaboratorsMap.get(audit.user)?.email" size="base" class="flex-none" />
<div class="flex-1 flex flex-col max-w-[calc(100%_-_44px)]">
<div class="w-full flex gap-3">
<NcTooltip
class="text-sm !leading-5 text-gray-800 capitalize font-semibold truncate"
show-on-truncate-only
placement="bottom"
>
<template #title>
{{
collaboratorsMap.get(audit.user)?.display_name ||
collaboratorsMap
.get(audit.user)
?.email?.slice(0, collaboratorsMap.get(audit.user)?.email.indexOf('@'))
}}
</template>
{{
collaboratorsMap.get(audit.user)?.display_name ||
collaboratorsMap
.get(audit.user)
?.email?.slice(0, collaboratorsMap.get(audit.user)?.email.indexOf('@'))
}}
</NcTooltip>
</div>
<NcTooltip class="text-xs !leading-4 text-gray-600 truncate" show-on-truncate-only placement="bottom">
<template #title>
{{ collaboratorsMap.get(audit.user)?.email }}
</template>
{{ collaboratorsMap.get(audit.user)?.email }}
</NcTooltip>
</div>
</div>
<template v-else>{{ audit.user }} </template>
</div>
</td>
<td class="cell-timestamp">
<div>
<NcTooltip placement="bottom">
<template #title> {{ parseStringDateTime(audit.created_at, 'D MMMM YYYY HH:mm') }}</template>
{{ timeAgo(audit.created_at) }}
</NcTooltip>
</div>
</td>
<td class="cell-base">
<div>
<div v-if="audit.base_id" class="w-full">
<NcTooltip
class="truncate text-sm !leading-5 text-gray-800 font-semibold"
show-on-truncate-only
placement="bottom"
>
<template #title>
{{ bases.get(audit.base_id)?.title }}
</template>
{{ bases.get(audit.base_id)?.title }}
</NcTooltip>
<div class="text-gray-600 text-xs">ID: {{ audit.base_id }}</div>
</div>
<template v-else>
{{ audit.base_id }}
</template>
</div>
</td>
<td class="cell-type">
<div>
<div class="truncate bg-gray-200 px-2 py-1 rounded-lg">
<NcTooltip class="truncate" placement="bottom" show-on-truncate-only>
<template #title> {{ auditOperationTypeLabels[audit.op_type] }}</template>
<span class="truncate"> {{ auditOperationTypeLabels[audit.op_type] }} </span>
</NcTooltip>
</div>
</div>
</td>
<td class="cell-sub-type">
<div>
<div class="truncate">
<NcTooltip class="truncate" placement="bottom" show-on-truncate-only>
<template #title> {{ auditOperationSubTypeLabels[audit.op_sub_type] }}</template>
<span class="truncate"> {{ auditOperationSubTypeLabels[audit.op_sub_type] }} </span>
</NcTooltip>
</div>
</div>
</td>
<td class="cell-description">
<div>
<div class="truncate">
{{ audit.description }}
</div>
</div>
</td>
<td class="cell-ip">
<div>
<div class="truncate">
{{ audit.ip }}
</div>
</div>
</td>
</tr>
</tbody>
</table>
</template>
</div>
<div
v-show="isLoading"
class="flex items-center justify-center absolute left-0 top-0 w-full h-full z-10 pb-10 pointer-events-none"
>
<div class="flex flex-col justify-center items-center gap-2">
<GeneralLoader size="xlarge" />
<span class="text-center">{{ $t('general.loading') }}</span>
</div>
</div>
<div
v-if="!isLoading && !audits?.length"
class="flex items-center justify-center absolute left-0 top-[54px] w-full h-[calc(100%_-_54px)] pb-10 flex items-center justify-center text-gray-500"
>
<a-empty :image="Empty.PRESENTED_IMAGE_SIMPLE" :description="$t('labels.noData')" />
</div>
<div
v-if="totalRows"
class="flex flex-row justify-center items-center bg-gray-50 min-h-10"
:class="{
'pointer-events-none': isLoading,
}"
>
<div class="flex justify-between items-center w-full px-6">
<div>&nbsp;</div>
<NcPagination
v-model:current="currentPage"
v-model:page-size="currentLimit"
:total="+totalRows"
show-size-changer
:use-stored-page-size="false"
@update:current="loadAudits(undefined, undefined, false)"
@update:page-size="loadAudits(currentPage, $event, false)"
/>
<div class="text-gray-500 text-xs">{{ totalRows }} {{ totalRows === 1 ? 'record' : 'records' }}</div>
</div>
</div>
</div>
<NcModal v-model:visible="isRowExpanded" size="medium" :show-separator="false" @keydown.esc="isRowExpanded = false">
<template #header>
<div class="flex items-center justify-between gap-x-2 w-full">
<div class="flex-1 text-base font-weight-700 text-gray-900">Audit Details</div>
<div class="flex items-center gap-2">
<NcTooltip placement="bottom" class="text-gray-600 text-small leading-[18px]">
<template #title> {{ parseStringDateTime(selectedAudit.created_at, 'D MMMM YYYY HH:mm') }}</template>
{{ timeAgo(selectedAudit.created_at) }}
</NcTooltip>
</div>
</div>
</template>
<div v-if="selectedAudit" class="flex flex-col gap-4">
<div class="bg-gray-50 rounded-lg border-1 border-gray-200">
<div class="flex">
<div class="w-1/2 border-r border-gray-200 flex flex-col gap-2 px-4 py-3">
<div class="cell-header">Performed by</div>
<div
v-if="selectedAudit?.user && collaboratorsMap.get(selectedAudit.user)?.email"
class="w-full flex gap-3 items-center"
>
<GeneralUserIcon :email="collaboratorsMap.get(selectedAudit.user)?.email" size="base" class="flex-none" />
<div class="flex-1 flex flex-col">
<div class="w-full flex gap-3">
<span class="text-sm text-gray-800 capitalize font-semibold">
{{
collaboratorsMap.get(selectedAudit.user)?.display_name ||
collaboratorsMap
.get(selectedAudit.user)
?.email?.slice(0, collaboratorsMap.get(selectedAudit.user)?.email.indexOf('@'))
}}
</span>
</div>
<span class="text-xs text-gray-600">
{{ collaboratorsMap.get(selectedAudit.user)?.email }}
</span>
</div>
</div>
<div v-else class="text-small leading-[18px] text-gray-600">{{ selectedAudit?.user }}</div>
</div>
<div class="w-1/2 flex flex-col gap-2 px-4 py-3">
<div class="cell-header">{{ $t('general.ipAddress') }}</div>
<div class="text-small leading-[18px] text-gray-600">{{ selectedAudit?.ip }}</div>
</div>
</div>
<div class="border-t-1 border-gray-200 flex">
<div class="w-1/2 border-r border-gray-200 flex flex-col gap-2 px-4 py-3">
<div class="cell-header">{{ $t('objects.project') }}</div>
<div v-if="selectedAudit?.base_id && bases.get(selectedAudit?.base_id)" class="flex items-stretch gap-3">
<div class="flex items-center">
<GeneralProjectIcon
:color="bases.get(selectedAudit?.base_id)?.meta?.iconColor"
:type="bases.get(selectedAudit?.base_id)?.type || 'database'"
class="nc-view-icon w-5 h-5"
/>
</div>
<div>
<div class="text-sm font-weight-500 text-gray-900">{{ bases.get(selectedAudit?.base_id)?.title }}</div>
<div class="text-small leading-[18px] text-gray-600">{{ selectedAudit?.base_id }}</div>
</div>
</div>
<template v-else>
{{ selectedAudit.base_id }}
</template>
</div>
<div class="w-1/2">
<div class="h-1/2 border-b border-gray-200 flex items-center gap-2 px-4 py-3">
<div class="cell-header">{{ $t('general.type') }}</div>
<div class="text-small leading-[18px] text-gray-600 bg-gray-200 px-1 rounded-md">
{{ auditOperationTypeLabels[selectedAudit?.op_type] }}
</div>
</div>
<div class="h-1/2 flex items-center gap-2 px-4 py-3">
<div class="cell-header">{{ $t('general.subType') }}</div>
<div class="text-small leading-[18px] text-gray-600">
{{ auditOperationSubTypeLabels[selectedAudit?.op_sub_type] }}
</div>
</div>
</div>
</div>
</div>
<div class="flex flex-col gap-2">
<div class="cell-header">{{ $t('labels.description') }}</div>
<div class="text-small leading-[18px] text-gray-600">{{ selectedAudit?.description }}</div>
</div>
</div>
</NcModal>
</template>
</div>
</template>
<style lang="scss" scoped>
.nc-audit-table pre {
display: table;
table-layout: fixed;
width: 100%;
white-space: break-spaces;
font-size: unset;
font-family: unset;
}
:deep(.nc-menu-item-inner) {
@apply !w-full;
}
.table-container {
&.bordered {
@apply border-1 border-gray-200 rounded-lg overflow-hidden;
}
}
.nc-audit-logs-table {
&.sticky-shadow {
th,
td {
&.cell-user {
@apply border-r-1 border-gray-200;
}
}
}
&:not(.sticky-shadow) {
th,
td {
&.cell-user {
@apply border-r-1 border-transparent;
}
}
}
thead {
th {
@apply bg-gray-50 text-sm text-gray-500 font-weight-500;
&.cell-user {
@apply sticky left-0 z-4 bg-gray-50;
}
}
}
tbody {
tr {
@apply cursor-pointer;
.td {
@apply text-small leading-[18px] text-gray-600;
}
td {
&.cell-user {
@apply sticky left-0 z-4 bg-white;
}
}
}
}
tr {
@apply h-[54px] flex border-b-1 border-gray-200;
&:hover td {
@apply !bg-gray-50;
}
&.selected td {
@apply !bg-gray-50;
}
th,
td {
@apply h-full;
& > div {
@apply px-6 h-full flex items-center;
}
&.cell-user {
@apply w-[220px] sticky left-0 z-5;
}
&.cell-timestamp,
&.cell-base,
&.cell-ip {
@apply w-[180px];
}
&.cell-type {
@apply w-[118px];
}
&.cell-sub-type {
@apply w-[150px];
}
&.cell-description {
@apply w-[472px];
}
}
}
}
.cell-header {
@apply text-xs font-semibold text-gray-500;
}
:deep(.nc-button) {
svg.sort-asc path.up {
@apply !stroke-brand-500;
}
svg.sort-desc path.down {
@apply !stroke-brand-500;
}
}
:deep(.nc-menu-item::after) {
content: none;
}
:deep(.ant-menu.nc-menu) {
@apply !pt-0 !border-none !rounded-none;
&.nc-audit-date-range-menu {
@apply !pb-0;
}
}
.nc-audit-custom-date-range-input {
@apply border-1 border-gray-200 rounded-lg pr-2 py-1 transition-all duration-0.3s shadow-default focus-within:(border-brand-500 shadow-selected);
&:hover:not(:focus-within) {
@apply shadow-hover;
}
:deep(.ant-picker-input > input) {
@apply !px-2;
}
}
</style>

21
packages/nc-gui/components/workspace/View.vue

@ -69,10 +69,9 @@ onMounted(() => {
<template>
<div v-if="currentWorkspace" class="flex w-full px-6 max-w-[97.5rem] flex-col nc-workspace-settings">
<div v-if="!props.workspaceId" class="flex gap-2 items-center min-w-0 py-6">
<GeneralWorkspaceIcon :workspace="currentWorkspace" />
<h1 class="text-3xl capitalize font-weight-bold tracking-[0.5px] mb-0 nc-workspace-title truncate min-w-10 capitalize">
{{ currentWorkspace?.title }}
<div v-if="!props.workspaceId" class="flex gap-2 items-center min-w-0 py-4">
<h1 class="text-base capitalize font-weight-bold tracking-[0.5px] mb-0 nc-workspace-title truncate min-w-10 capitalize">
{{ currentWorkspace?.title }} > {{ $t('title.teamAndSettings') }}
</h1>
</div>
<div v-else>
@ -120,6 +119,20 @@ onMounted(() => {
<WorkspaceSettings :workspace-id="currentWorkspace.id" />
</a-tab-pane>
</template>
<template v-if="isUIAllowed('workspaceAuditList') && !props.workspaceId">
<a-tab-pane key="audit" class="w-full">
<template #tab>
<div class="flex flex-row items-center px-2 pb-1 gap-x-1.5">
<GeneralIcon icon="audit" class="!h-3.5 !w-3.5" />
Audit Logs
</div>
</template>
<div class="h-[calc(100vh-92px)]">
<WorkspaceAuditLogs :workspace-id="currentWorkspace.id" />
</div>
</a-tab-pane>
</template>
</NcTabs>
</div>
</template>

7
packages/nc-gui/lang/en.json

@ -155,6 +155,7 @@
"updating": "Updating",
"rename": "Rename",
"reload": "Reload",
"refresh": "Refresh",
"reset": "Reset",
"install": "Install",
"show": "Show",
@ -223,6 +224,7 @@
"move": "Move",
"geoDataField": "GeoData Field",
"type": "Type",
"subType": "Sub-Type",
"name": "Name",
"changes": "Changes",
"new": "New",
@ -265,7 +267,8 @@
"format": "Format",
"colour": "Colour",
"use": "Use",
"stack": "Stack"
"stack": "Stack",
"ipAddress": "IP Address"
},
"objects": {
"owner": "Owner",
@ -454,7 +457,7 @@
"uiACL": "UI Access Control",
"metaOperations": "Metadata Operations",
"audit": "Audit",
"auditLogs": "Audit Log",
"auditLogs": "Audit Logs",
"sqlMigrations": "SQL Migrations",
"dbCredentials": "Database Credentials",
"advancedParameters": "SSL & Advanced parameters",

2
packages/nc-gui/lib/acl.ts

@ -39,6 +39,7 @@ const rolePermissions = {
viewCreateOrEdit: true,
baseReorder: true,
orgAdminPanel: true,
workspaceAuditList: true,
},
},
[OrgUserRoles.VIEWER]: {
@ -84,6 +85,7 @@ const rolePermissions = {
baseRename: true,
baseDuplicate: true,
sourceCreate: true,
baseAuditList: true,
},
},
[ProjectRoles.EDITOR]: {

8
packages/nc-gui/lib/enums.ts

@ -169,3 +169,11 @@ export enum CoverImageObjectFit {
FIT = 'fit',
COVER = 'cover',
}
export enum AuditLogsDateRange {
Last24H = 'last24H',
PastWeek = 'pastWeek',
PastMonth = 'pastMonth',
PastYear = 'pastYear',
Custom = 'custom',
}

31
packages/nc-gui/lib/types.ts

@ -1,8 +1,19 @@
import type { BaseType, ColumnType, FilterType, MetaType, PaginatedType, Roles, RolesObj, TableType, ViewTypes } from 'nocodb-sdk'
import type {
AuditOperationTypes,
BaseType,
ColumnType,
FilterType,
MetaType,
PaginatedType,
Roles,
RolesObj,
TableType,
ViewTypes,
} from 'nocodb-sdk'
import type { I18n } from 'vue-i18n'
import type { Theme as AntTheme } from 'ant-design-vue/es/config-provider'
import type { UploadFile } from 'ant-design-vue'
import type { ImportSource, ImportType, PreFilledMode, TabType } from './enums'
import type { AuditLogsDateRange, ImportSource, ImportType, PreFilledMode, TabType } from './enums'
import type { rolePermissions } from './acl'
interface User {
@ -226,6 +237,21 @@ interface ImageCropperConfig {
imageRestriction?: 'fill-area' | 'fit-area' | 'stencil' | 'none'
}
interface AuditLogsQuery {
type?: AuditOperationTypes
baseId?: string
sourceId?: string
user?: string
startDate?: string
endDate?: string
dateRange?: AuditLogsDateRange
dateRangeLabel?: string
orderBy: {
created_at?: 'asc' | 'desc'
user?: 'asc' | 'desc'
}
}
export type {
User,
ProjectMetaInfo,
@ -256,4 +282,5 @@ export type {
CalendarRangeType,
FormFieldsLimitOptionsType,
ImageCropperConfig,
AuditLogsQuery,
}

16
packages/nc-gui/pages/account/index.vue

@ -84,6 +84,20 @@ const logout = async () => {
<div class="select-none">API {{ $t('title.tokens') }}</div>
</div>
</NcMenuItem>
<NcMenuItem
key="audit"
class="item"
:class="{
active: $route.params.page === 'audit',
}"
@click="navigateTo('/account/audit')"
>
<div class="flex items-center space-x-2">
<component :is="iconMap.audit" class="opacity-80" />
<div class="select-none">{{ $t('title.auditLogs') }}</div>
</div>
</NcMenuItem>
<NcMenuItem
v-if="isUIAllowed('superAdminAppStore') && !isEeUI"
key="apps"
@ -143,7 +157,7 @@ const logout = async () => {
<!-- Sub Tabs -->
<div class="flex flex-col w-full ml-65">
<div class="flex flex-col w-full pl-65">
<div class="flex flex-row p-3 items-center h-14">
<div class="flex-1" />

3
packages/nc-gui/pages/account/index/[page].vue

@ -5,6 +5,9 @@ const { appInfo } = useGlobal()
<template>
<div>
<AccountToken v-if="$route.params.page === 'tokens'" />
<div v-else-if="$route.params.page === 'audit'" class="h-[calc(100vh_-_4rem)] w-full px-6">
<WorkspaceAuditLogs />
</div>
<AccountProfile v-else-if="$route.params.page === 'profile'" />
<AccountAppStore v-else-if="$route.params.page === 'apps' && !appInfo.isCloud" />
<span v-else></span>

63
packages/nc-gui/store/workspace.ts

@ -1,11 +1,23 @@
import type { BaseType } from 'nocodb-sdk'
import type { AuditType, BaseType } from 'nocodb-sdk'
import { acceptHMRUpdate, defineStore } from 'pinia'
import { message } from 'ant-design-vue'
import { isString } from '@vue/shared'
import type { AuditLogsQuery } from '~/lib/types'
const defaultAuditLogsQuery = {
baseId: undefined,
sourceId: undefined,
orderBy: {
created_at: 'desc',
user: undefined,
},
} as Partial<AuditLogsQuery>
export const useWorkspace = defineStore('workspaceStore', () => {
const basesStore = useBases()
const { isUIAllowed } = useRoles()
const collaborators = ref<any[] | null>()
const router = useRouter()
@ -211,6 +223,49 @@ export const useWorkspace = defineStore('workspaceStore', () => {
}
}
const auditLogsQuery = ref<Partial<AuditLogsQuery>>(defaultAuditLogsQuery)
const audits = ref<null | Array<AuditType>>(null)
const auditTotalRows = ref(0)
const auditCurrentPage = ref(1)
const auditCurrentLimit = ref(25)
const loadAudits = async (
_workspaceId?: string,
page: number = auditCurrentPage.value,
limit: number = auditCurrentLimit.value,
) => {
try {
if (limit * (page - 1) > auditTotalRows.value) {
auditCurrentPage.value = 1
page = 1
}
const { list, pageInfo } = isUIAllowed('workspaceAuditList')
? await $api.utils.projectAuditList({
offset: limit * (page - 1),
limit,
...auditLogsQuery.value,
})
: await $api.base.auditList(auditLogsQuery.value.baseId, {
offset: limit * (page - 1),
limit,
...auditLogsQuery.value,
})
audits.value = list
auditTotalRows.value = pageInfo.totalRows ?? 0
} catch (e) {
message.error(await extractSdkResponseErrorMsg(e))
audits.value = []
auditTotalRows.value = 0
auditCurrentPage.value = 1
}
}
function setLoadingState(isLoading = false) {
isWorkspaceLoading.value = isLoading
}
@ -256,6 +311,12 @@ export const useWorkspace = defineStore('workspaceStore', () => {
getPlanLimit,
workspaceRole,
moveToOrg,
auditLogsQuery,
audits,
auditTotalRows,
auditCurrentPage,
auditCurrentLimit,
loadAudits,
}
})

4
packages/nc-gui/utils/iconUtils.ts

@ -114,6 +114,7 @@ import NcCommentHere from '~icons/nc-icons/comment-here'
import NcAddDataSource from '~icons/nc-icons/add-data-source'
import NcDatabaseIcon from '~icons/nc-icons/database'
import NcChevronDown from '~icons/nc-icons/chevron-down'
import NcChevronUpDown from '~icons/nc-icons/chevron-up-down'
import NcTrash from '~icons/nc-icons/trash'
import NcPencil from '~icons/nc-icons/pencil'
import NcRename from '~icons/nc-icons/rename'
@ -201,6 +202,7 @@ import NcMinimizeAll from '~icons/nc-icons/minimize-all'
import NcMaximize from '~icons/nc-icons/maximize'
import NcMaximizeAll from '~icons/nc-icons/maximize-all'
import NcDrag from '~icons/nc-icons/drag'
import NcRefresh from '~icons/nc-icons/refresh'
// keep it for reference
// todo: remove it after all icons are migrated
@ -632,6 +634,8 @@ export const iconMap = {
maximize: NcMaximize,
maximizeAll: NcMaximizeAll,
ncDrag: NcDrag,
refresh: NcRefresh,
chevronUpDown: NcChevronUpDown,
}
export const getMdiIcon = (type: string): any => {

49
packages/nocodb-sdk/src/lib/globals.ts

@ -38,22 +38,38 @@ export enum AuditOperationTypes {
TABLE = 'TABLE',
VIEW = 'VIEW',
META = 'META',
TABLE_COLUMN = 'TABLE_COLUMN',
WEBHOOKS = 'WEBHOOKS',
AUTHENTICATION = 'AUTHENTICATION',
TABLE_COLUMN = 'TABLE_COLUMN',
ORG_USER = 'ORG_USER',
}
export const auditOperationTypeLabels = {
[AuditOperationTypes.COMMENT]: 'Comment',
[AuditOperationTypes.DATA]: 'Data',
[AuditOperationTypes.PROJECT]: 'Project',
[AuditOperationTypes.VIRTUAL_RELATION]: 'Virtual Relation',
[AuditOperationTypes.RELATION]: 'Relation',
[AuditOperationTypes.TABLE_VIEW]: 'Table View',
[AuditOperationTypes.TABLE]: 'Table',
[AuditOperationTypes.VIEW]: 'View',
[AuditOperationTypes.META]: 'Meta',
[AuditOperationTypes.WEBHOOKS]: 'Webhooks',
[AuditOperationTypes.AUTHENTICATION]: 'Authentication',
[AuditOperationTypes.TABLE_COLUMN]: 'Table Column',
[AuditOperationTypes.ORG_USER]: 'Org User',
};
export enum AuditOperationSubTypes {
UPDATE = 'UPDATE',
INSERT = 'INSERT',
CREATE = 'CREATE',
UPDATE = 'UPDATE',
DELETE = 'DELETE',
BULK_INSERT = 'BULK_INSERT',
BULK_UPDATE = 'BULK_UPDATE',
BULK_DELETE = 'BULK_DELETE',
LINK_RECORD = 'LINK_RECORD',
UNLINK_RECORD = 'UNLINK_RECORD',
DELETE = 'DELETE',
CREATE = 'CREATE',
RENAME = 'RENAME',
IMPORT_FROM_ZIP = 'IMPORT_FROM_ZIP',
EXPORT_TO_FS = 'EXPORT_TO_FS',
@ -69,6 +85,31 @@ export enum AuditOperationSubTypes {
RESEND_INVITE = 'RESEND_INVITE',
}
export const auditOperationSubTypeLabels = {
[AuditOperationSubTypes.UPDATE]: 'Update',
[AuditOperationSubTypes.INSERT]: 'Insert',
[AuditOperationSubTypes.DELETE]: 'Delete',
[AuditOperationSubTypes.BULK_INSERT]: 'Bulk Insert',
[AuditOperationSubTypes.BULK_UPDATE]: 'Bulk Update',
[AuditOperationSubTypes.BULK_DELETE]: 'Bulk Delete',
[AuditOperationSubTypes.LINK_RECORD]: 'Link Record',
[AuditOperationSubTypes.UNLINK_RECORD]: 'Unlink Record',
[AuditOperationSubTypes.CREATE]: 'Create',
[AuditOperationSubTypes.RENAME]: 'Rename',
[AuditOperationSubTypes.IMPORT_FROM_ZIP]: 'Import From Zip',
[AuditOperationSubTypes.EXPORT_TO_FS]: 'Export To FS',
[AuditOperationSubTypes.EXPORT_TO_ZIP]: 'Export To Zip',
[AuditOperationSubTypes.SIGNIN]: 'Signin',
[AuditOperationSubTypes.SIGNUP]: 'Signup',
[AuditOperationSubTypes.PASSWORD_RESET]: 'Password Reset',
[AuditOperationSubTypes.PASSWORD_FORGOT]: 'Password Forgot',
[AuditOperationSubTypes.PASSWORD_CHANGE]: 'Password Change',
[AuditOperationSubTypes.EMAIL_VERIFICATION]: 'Email Verification',
[AuditOperationSubTypes.ROLES_MANAGEMENT]: 'Roles Management',
[AuditOperationSubTypes.INVITE]: 'Invite',
[AuditOperationSubTypes.RESEND_INVITE]: 'Resend Invite',
};
export enum PluginCategory {
STORAGE = 'Storage',
EMAIL = 'Email',

20
packages/nocodb/src/controllers/audits.controller.ts

@ -19,7 +19,7 @@ import { NcContext, NcRequest } from '~/interface/config';
@Controller()
@UseGuards(MetaApiLimiterGuard, GlobalGuard)
export class AuditsController {
constructor(private readonly auditsService: AuditsService) {}
constructor(protected readonly auditsService: AuditsService) {}
@Get(['/api/v1/db/meta/audits/', '/api/v2/meta/audits/'])
@Acl('auditList')
@ -58,7 +58,23 @@ export class AuditsController {
baseId,
}),
{
count: await this.auditsService.auditCount({ baseId }),
count: await this.auditsService.auditCount({
query: req.query,
baseId,
}),
...req.query,
},
);
}
@Get(['/api/v1/db/meta/projects/audits/', '/api/v2/meta/projects/audits/'])
@Acl('projectAuditList')
async projectAuditList(@Req() req: NcRequest) {
return new PagedResponseImpl(
await this.auditsService.projectAuditList({
query: req.query,
}),
{
count: await this.auditsService.projectAuditCount(),
...req.query,
},
);

75
packages/nocodb/src/models/Audit.ts

@ -141,10 +141,15 @@ export default class Audit implements AuditType {
limit = 25,
offset = 0,
sourceId,
orderBy,
}: {
limit?: number;
offset?: number;
sourceId?: string;
orderBy?: {
created_at?: 'asc' | 'desc';
user?: 'asc' | 'desc';
};
},
) {
return await Noco.ncMeta.metaList2(
@ -157,7 +162,12 @@ export default class Audit implements AuditType {
...(sourceId ? { source_id: sourceId } : {}),
},
orderBy: {
created_at: 'desc',
...(orderBy?.created_at
? { created_at: orderBy?.created_at }
: !orderBy?.user
? { created_at: 'desc' }
: {}),
...(orderBy?.user ? { user: orderBy?.user } : {}),
},
limit,
offset,
@ -167,18 +177,23 @@ export default class Audit implements AuditType {
static async baseAuditCount(
baseId: string,
sourceId?: string,
{
sourceId,
}: {
sourceId?: string;
},
): Promise<number> {
return (
await Noco.ncMeta
.knex(MetaTable.AUDIT)
.where({
return await Noco.ncMeta.metaCount(
RootScopes.ROOT,
RootScopes.ROOT,
MetaTable.AUDIT,
{
condition: {
base_id: baseId,
...(sourceId ? { source_id: sourceId } : {}),
})
.count('id', { as: 'count' })
.first()
)?.count;
},
},
);
}
static async sourceAuditList(sourceId: string, { limit = 25, offset = 0 }) {
@ -206,4 +221,44 @@ export default class Audit implements AuditType {
.first()
)?.count;
}
static async projectAuditList({
limit = 25,
offset = 0,
orderBy,
}: {
limit?: number;
offset?: number;
orderBy?: {
created_at?: 'asc' | 'desc';
user?: 'asc' | 'desc';
};
}) {
return await Noco.ncMeta.metaList2(
RootScopes.ROOT,
RootScopes.ROOT,
MetaTable.AUDIT,
{
limit,
offset,
orderBy: {
...(orderBy?.created_at
? { created_at: orderBy?.created_at }
: !orderBy?.user
? { created_at: 'desc' }
: {}),
...(orderBy?.user ? { user: orderBy?.user } : {}),
},
},
);
}
static async projectAuditCount(): Promise<number> {
return await Noco.ncMeta.metaCount(
RootScopes.ROOT,
RootScopes.ROOT,
MetaTable.AUDIT,
{},
);
}
}

4
packages/nocodb/src/models/View.ts

@ -1870,9 +1870,9 @@ export default class View implements ViewType {
}
} else if (view.type === ViewTypes.KANBAN && !copyFromView) {
const kanbanView = await KanbanView.get(context, view.id, ncMeta);
if (column.id === kanbanView?.fk_grp_col_id) {
if (column.id === kanbanView?.fk_grp_col_id && column.pv) {
// include grouping field if it exists
show = column.pv ? true : false;
show = true;
} else if (
(column.id === kanbanView.fk_cover_image_col_id && column.pv) ||
(column.id !== kanbanView.fk_cover_image_col_id && column.pv)

124
packages/nocodb/src/schema/swagger-v2.json

@ -8987,6 +8987,100 @@
]
}
},
"/api/v2/meta/projects/audits": {
"parameters": [
{
"$ref": "#/components/parameters/xc-auth"
}
],
"get": {
"summary": "List Audits in Project",
"operationId": "project-audit-list",
"responses": {
"200": {
"description": "OK",
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"list": {
"type": "array",
"uniqueItems": true,
"items": {
"$ref": "#/components/schemas/Audit"
}
},
"pageInfo": {
"$ref": "#/components/schemas/Paginated"
}
},
"required": [
"list",
"pageInfo"
]
}
}
}
},
"400": {
"$ref": "#/components/responses/BadRequest"
}
},
"description": "List all audit data in the given project",
"parameters": [
{
"schema": {
"type": "integer",
"minimum": 0
},
"in": "query",
"name": "offset"
},
{
"schema": {
"type": "integer",
"minimum": 1
},
"in": "query",
"name": "limit"
},
{
"schema": {
"properties": {
"created_at": {
"type": "string",
"description": "Sort direction",
"enum": [
"asc",
"desc"
],
"example": "desc"
},
"user": {
"type": "string",
"description": "Sort direction",
"enum": [
"asc",
"desc"
],
"example": "desc"
}
},
"type": "object"
},
"in": "query",
"name": "orderBy"
},
{
"$ref": "#/components/parameters/xc-auth"
}
],
"tags": [
"Utils"
]
}
},
"/api/v2/meta/bases/{baseId}/audits": {
"parameters": [
{
@ -9015,7 +9109,6 @@
"list": {
"type": "array",
"uniqueItems": true,
"minItems": 1,
"items": {
"$ref": "#/components/schemas/Audit"
}
@ -9049,7 +9142,7 @@
{
"schema": {
"type": "integer",
"maximum": 1
"minimum": 1
},
"in": "query",
"name": "limit"
@ -9061,6 +9154,33 @@
"in": "query",
"name": "sourceId"
},
{
"schema": {
"properties": {
"created_at": {
"type": "string",
"description": "Sort direction",
"enum": [
"asc",
"desc"
],
"example": "desc"
},
"user": {
"type": "string",
"description": "Sort direction",
"enum": [
"asc",
"desc"
],
"example": "desc"
}
},
"type": "object"
},
"in": "query",
"name": "orderBy"
},
{
"$ref": "#/components/parameters/xc-auth"
}

124
packages/nocodb/src/schema/swagger.json

@ -14082,6 +14082,100 @@
]
}
},
"/api/v1/db/meta/projects/audits": {
"parameters": [
{
"$ref": "#/components/parameters/xc-auth"
}
],
"get": {
"summary": "List Audits in Project",
"operationId": "project-audit-list",
"responses": {
"200": {
"description": "OK",
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"list": {
"type": "array",
"uniqueItems": true,
"items": {
"$ref": "#/components/schemas/Audit"
}
},
"pageInfo": {
"$ref": "#/components/schemas/Paginated"
}
},
"required": [
"list",
"pageInfo"
]
}
}
}
},
"400": {
"$ref": "#/components/responses/BadRequest"
}
},
"description": "List all audit data in the given project",
"parameters": [
{
"schema": {
"type": "integer",
"minimum": 0
},
"in": "query",
"name": "offset"
},
{
"schema": {
"type": "integer",
"minimum": 1
},
"in": "query",
"name": "limit"
},
{
"schema": {
"properties": {
"created_at": {
"type": "string",
"description": "Sort direction",
"enum": [
"asc",
"desc"
],
"example": "desc"
},
"user": {
"type": "string",
"description": "Sort direction",
"enum": [
"asc",
"desc"
],
"example": "desc"
}
},
"type": "object"
},
"in": "query",
"name": "orderBy"
},
{
"$ref": "#/components/parameters/xc-auth"
}
],
"tags": [
"Utils"
]
}
},
"/api/v1/db/meta/projects/{baseId}/audits": {
"parameters": [
{
@ -14110,7 +14204,6 @@
"list": {
"type": "array",
"uniqueItems": true,
"minItems": 1,
"items": {
"$ref": "#/components/schemas/Audit"
}
@ -14144,7 +14237,7 @@
{
"schema": {
"type": "integer",
"maximum": 1
"minimum": 1
},
"in": "query",
"name": "limit"
@ -14156,6 +14249,33 @@
"in": "query",
"name": "sourceId"
},
{
"schema": {
"properties": {
"created_at": {
"type": "string",
"description": "Sort direction",
"enum": [
"asc",
"desc"
],
"example": "desc"
},
"user": {
"type": "string",
"description": "Sort direction",
"enum": [
"asc",
"desc"
],
"example": "desc"
}
},
"type": "object"
},
"in": "query",
"name": "orderBy"
},
{
"$ref": "#/components/parameters/xc-auth"
}

18
packages/nocodb/src/services/audits.service.ts

@ -74,14 +74,22 @@ export class AuditsService {
}
async auditCount(param: { query?: any; baseId: string }) {
return await Audit.baseAuditCount(param.baseId, param.query?.sourceId);
return await Audit.baseAuditCount(param.baseId, param.query);
}
async baseAuditList(param: { query: any; sourceId: any }) {
return await Audit.baseAuditList(param.sourceId, param.query);
async sourceAuditList(param: { query: any; sourceId: any }) {
return await Audit.sourceAuditList(param.sourceId, param.query);
}
async baseAuditCount(param: { sourceId: string }) {
return await Audit.baseAuditCount(param.sourceId);
async sourceAuditCount(param: { query: any; sourceId: string }) {
return await Audit.sourceAuditCount(param.sourceId);
}
async projectAuditList(param: { query: any }) {
return await Audit.projectAuditList(param.query);
}
async projectAuditCount() {
return await Audit.projectAuditCount();
}
}

5
tests/playwright/pages/Dashboard/common/Cell/index.ts

@ -144,6 +144,11 @@ export class CellPageObject extends BasePage {
await this.rootPage.locator(`td[data-testid="cell-${columnHeader}-${index}"]`).waitFor({ state: 'visible' });
}
await this.get({
index,
columnHeader,
}).waitFor({ state: 'visible' });
await this.get({
index,
columnHeader,

2
tests/playwright/tests/db/features/verticalFillHandle.spec.ts

@ -31,6 +31,8 @@ async function dragDrop({
// drag and drop
await src.dragTo(dst);
await params.dashboard.rootPage.waitForTimeout(250);
}
async function beforeEachInit({ page, tableType }: { page: any; tableType: string }) {
const context = await setup({ page, isEmptyProject: true });

Loading…
Cancel
Save