diff --git a/frontend/assets/css/overrides.css b/frontend/assets/css/overrides.css index 7ad0cfd..6876964 100644 --- a/frontend/assets/css/overrides.css +++ b/frontend/assets/css/overrides.css @@ -18,9 +18,12 @@ div.p-toast { width: auto; } -.code { +.code, .code-block { font-family: monospace; background-color: #eee; - padding: .25rem .5rem; - border-radius: .25rem; + padding: .5rem; + border-radius: .5rem; + display: inline-block; + font-size: 0.75rem; + white-space: pre-wrap; } \ No newline at end of file diff --git a/frontend/components/standard/Debug.vue b/frontend/components/standard/Debug.vue index 81377a2..de113ba 100644 --- a/frontend/components/standard/Debug.vue +++ b/frontend/components/standard/Debug.vue @@ -40,7 +40,7 @@ function createCircularReplacer (): (this: any, key: string, value: any) => any header-class="surface-800" >
{{ valueAsStr }}
@@ -53,14 +53,6 @@ function createCircularReplacer (): (this: any, key: string, value: any) => any width: fit-content; display: inline-block; - .code { - display: inline-block; - font-size: 0.75rem; - line-height: 0.75rem; - white-space: pre-wrap; - font-family: monospace; - } - .p-accordion-header .p-accordion-header-link { gap: 1rem; padding: 0.5rem 0.75rem; diff --git a/frontend/lang/en.json b/frontend/lang/en.json index 7ea3d43..8c0528a 100644 --- a/frontend/lang/en.json +++ b/frontend/lang/en.json @@ -307,5 +307,22 @@ "Uploading": "Uploading", "Validating": "Validating", "Waiting": "Waiting" + }, + "pages/audit-logs": { + "Action": "Action", + "Actor": "Actor", + "Actor ID": "Actor ID", + "Actor Owner ID": "Actor Owner ID", + "Actor Type": "Actor Type", + "Time": "Time", + "ID": "ID", + "Primary Target": "Primary Target", + "Primary Target ID": "Primary Target ID", + "Primary Target Owner": "Primary Target Owner", + "Primary Target Type": "Primary Target Type", + "Secondary Target": "Secondary Target", + "Secondary Target ID": "Secondary Target ID", + "Secondary Target Owner": "Secondary Target Owner", + "Secondary Target Type": "Secondary Target Type" } } diff --git a/frontend/lib/auditlogquery/index.ts b/frontend/lib/auditlogquery/index.ts new file mode 100644 index 0000000..5b690c5 --- /dev/null +++ b/frontend/lib/auditlogquery/index.ts @@ -0,0 +1,200 @@ +import { type AuditLogQueryReq, type AuditLogQuerySort, type AuditLogQueryWhere, type AuditLogQuerySortBy } from '@/openapi/generated/pacta' +import { type WritableComputedRef } from 'vue' +import { type LocalePathFunction } from 'vue-i18n-routing' +import { computed } from 'vue' + +const encodeAuditLogQuerySorts = (sorts: AuditLogQuerySort[]): string => { + const components: string[] = [] + for (const sort of sorts) { + components.push(`${sort.ascending ? 'A' : 'D'}:${sort.by.replace('AuditLogQuerySortBy', '')}`) + } + return components.join(',') +} + +const decodeAuditLogQuerySorts = (sorts: string): AuditLogQuerySort[] => { + const components = sorts.split(',') + const result: AuditLogQuerySort[] = [] + for (const component of components) { + if (component === '') { + continue + } + const [dir, byStr] = component.split(':') + result.push({ + ascending: dir === 'A', + by: 'AuditLogQuerySortBy' + byStr as AuditLogQuerySortBy, + }) + } + return result +} + +const encodeAuditLogQueryWheres = (wheres: AuditLogQueryWhere[]): string => { + const components: string[] = [] + for (const where of wheres) { + if (where.inAction) { + components.push(`Action:${where.inAction.join('|')}`) + } else if (where.inActorId) { + components.push(`ActorId:${where.inActorId.join('|')}`) + } else if (where.inActorType) { + components.push(`ActorType:${where.inActorType.join('|')}`) + } else if (where.inActorOwnerId) { + components.push(`ActorOwnerId:${where.inActorOwnerId.join('|')}`) + } else if (where.inId) { + components.push(`Id:${where.inId.join('|')}`) + } else if (where.inTargetType) { + components.push(`TargetType:${where.inTargetType.join('|')}`) + } else if (where.inTargetId) { + components.push(`TargetId:${where.inTargetId.join('|')}`) + } else if (where.inTargetOwnerId) { + components.push(`TargetOwnerId:${where.inTargetOwnerId.join('|')}`) + } else if (where.minCreatedAt) { + components.push(`MinCreatedAt:${where.minCreatedAt}`) + } else if (where.maxCreatedAt) { + components.push(`MaxCreatedAt:${where.maxCreatedAt}`) + } else { + console.warn(new Error(`Unknown where: ${JSON.stringify(where)}`)) + } + } + return components.join(',') +} + +const decodeAudtLogQueryWheres = (wheres: string): AuditLogQueryWhere[] => { + const components = wheres.split(',') + const result: AuditLogQueryWhere[] = [] + for (const component of components) { + if (component === '') { + continue + } + const [key, value] = component.split(':') + switch (key) { + case 'Action': + result.push({ + inAction: value.split('|') as AuditLogQueryWhere['inAction'], + }) + break + case 'ActorId': + result.push({ + inActorId: value.split('|') as AuditLogQueryWhere['inActorId'], + }) + break + case 'ActorType': + result.push({ + inActorType: value.split('|') as AuditLogQueryWhere['inActorType'], + }) + break + case 'ActorOwnerId': + result.push({ + inActorOwnerId: value.split('|') as AuditLogQueryWhere['inActorOwnerId'], + }) + break + case 'Id': + result.push({ + inId: value.split('|') as AuditLogQueryWhere['inId'], + }) + break + case 'TargetType': + result.push({ + inTargetType: value.split('|') as AuditLogQueryWhere['inTargetType'], + }) + break + case 'TargetId': + result.push({ + inTargetId: value.split('|') as AuditLogQueryWhere['inTargetId'], + }) + break + case 'TargetOwnerId': + result.push({ + inTargetOwnerId: value.split('|') as AuditLogQueryWhere['inTargetOwnerId'], + }) + break + case 'MinCreatedAt': + result.push({ + minCreatedAt: value, + }) + break + case 'MaxCreatedAt': + result.push({ + maxCreatedAt: value, + }) + break + default: + console.warn(new Error(`Unknown where: ${JSON.stringify(key)}`)) + } + } + return result +} + +const encodeAuditLogQueryLimit = (limit: number): string => { + if (limit === limitDefault) { + return '' + } + return `${limit}` +} + +const decodeAuditLogQueryLimit = (limit: string): number => { + if (limit === '') { + return limitDefault + } + return parseInt(limit) +} + +const encodeAuditLogQueryCursor = (cursor: string): string => { + return encodeURIComponent(cursor) +} + +const decodeAuditLogQueryCursor = (cursor: string): string => { + return decodeURIComponent(cursor) +} + +const sortsQP = 's' +const wheresQP = 'w' +const limitQP = 'l' +const limitDefault = 100 +const cursorQP = 'c' +const pageURLBase = '/auditlog' + +export const urlReactiveAuditLogQuery = (fromQueryReactiveWithDefault: (key: string, defaultValue: string) => WritableComputedRef): WritableComputedRef => { + const qSorts = fromQueryReactiveWithDefault(sortsQP, '') + const qWheres = fromQueryReactiveWithDefault(wheresQP, '') + const qLimit = fromQueryReactiveWithDefault(limitQP, '') + const qCursor = fromQueryReactiveWithDefault(cursorQP, '') + + return computed({ + get: (): AuditLogQueryReq => { + return { + sorts: decodeAuditLogQuerySorts(qSorts.value), + wheres: decodeAudtLogQueryWheres(qWheres.value), + limit: decodeAuditLogQueryLimit(qLimit.value), + cursor: decodeAuditLogQueryCursor(qCursor.value), + } + }, + set: (value: AuditLogQueryReq) => { + qSorts.value = encodeAuditLogQuerySorts(value.sorts ?? []) + qWheres.value = encodeAuditLogQueryWheres(value.wheres) + qLimit.value = encodeAuditLogQueryLimit(value.limit ?? limitDefault) + qCursor.value = encodeAuditLogQueryCursor(value.cursor ?? '') + }, + }) +} + +export const createURLAuditLogQuery = (localePath: LocalePathFunction, req: AuditLogQueryReq): string => { + const qSorts = encodeAuditLogQuerySorts(req.sorts ?? []) + const qWheres = encodeAuditLogQueryWheres(req.wheres) + const qLimit = encodeAuditLogQueryLimit(req.limit ?? limitDefault) + const qCursor = encodeAuditLogQueryCursor(req.cursor ?? '') + const q = new URLSearchParams() + if (qSorts) { + q.set(sortsQP, qSorts) + } + if (qWheres) { + q.set(wheresQP, qWheres) + } + if (qLimit) { + q.set(limitQP, qLimit) + } + if (qCursor) { + q.set(cursorQP, qCursor) + } + const url = new URL(pageURLBase) + url.search = q.toString() + return localePath(url.toString()) +} diff --git a/frontend/pages/audit-logs.vue b/frontend/pages/audit-logs.vue new file mode 100644 index 0000000..0a6abbe --- /dev/null +++ b/frontend/pages/audit-logs.vue @@ -0,0 +1,420 @@ + + + + + diff --git a/frontend/plugins/primevue.ts b/frontend/plugins/primevue.ts index 5726010..7017c90 100644 --- a/frontend/plugins/primevue.ts +++ b/frontend/plugins/primevue.ts @@ -20,6 +20,7 @@ import TabView from 'primevue/tabview' import Textarea from 'primevue/textarea' import Tooltip from 'primevue/tooltip' import Message from 'primevue/message' +import MultiSelect from 'primevue/multiselect' import OverlayPanel from 'primevue/overlaypanel' import ProgressSpinner from 'primevue/progressspinner' import ToastService from 'primevue/toastservice' @@ -44,6 +45,7 @@ export default defineNuxtPlugin(({ vueApp }) => { vueApp.component('PVInputText', InputText) vueApp.component('PVInputSwitch', InputSwitch) vueApp.component('PVMessage', Message) + vueApp.component('PVMultiSelect', MultiSelect) vueApp.component('PVOverlayPanel', OverlayPanel) vueApp.component('PVProgressSpinner', ProgressSpinner) vueApp.component('PVTabPanel', TabPanel)