mirror of https://github.com/nocodb/nocodb
Browse Source
* feat: csv export job * fix: duplicate job * feat: csv export job final * feat: data export extension POC * feat: data export final * fix: extensions & scroll --------- Co-authored-by: Raju Udava <86527202+dstala@users.noreply.github.com>pull/8891/head
Mert E
5 months ago
committed by
GitHub
46 changed files with 1455 additions and 315 deletions
@ -0,0 +1,63 @@ |
|||||||
|
const jobsState = createGlobalState(() => { |
||||||
|
const baseJobs = ref<Record<string, JobType[]>>({}) |
||||||
|
return { baseJobs } |
||||||
|
}) |
||||||
|
|
||||||
|
interface JobType { |
||||||
|
id: string |
||||||
|
job: string |
||||||
|
status: string |
||||||
|
result: Record<string, any> |
||||||
|
fk_user_id: string |
||||||
|
fk_workspace_id: string |
||||||
|
base_id: string |
||||||
|
created_at: Date |
||||||
|
updated_at: Date |
||||||
|
} |
||||||
|
|
||||||
|
export const useJobs = createSharedComposable(() => { |
||||||
|
const { baseJobs } = jobsState() |
||||||
|
|
||||||
|
const { $api } = useNuxtApp() |
||||||
|
|
||||||
|
const { base } = storeToRefs(useBase()) |
||||||
|
|
||||||
|
const activeBaseJobs = computed(() => { |
||||||
|
if (!base.value || !base.value.id) { |
||||||
|
return null |
||||||
|
} |
||||||
|
return baseJobs.value[base.value.id] |
||||||
|
}) |
||||||
|
|
||||||
|
const jobList = computed<JobType[]>(() => { |
||||||
|
return activeBaseJobs.value || [] |
||||||
|
}) |
||||||
|
|
||||||
|
const getJobsForBase = (baseId: string) => { |
||||||
|
return baseJobs.value[baseId] || [] |
||||||
|
} |
||||||
|
|
||||||
|
const loadJobsForBase = async (baseId?: string) => { |
||||||
|
if (!baseId) { |
||||||
|
baseId = base.value.id |
||||||
|
|
||||||
|
if (!baseId) { |
||||||
|
return |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
const jobs: JobType[] = await $api.jobs.list(baseId, {}) |
||||||
|
|
||||||
|
if (baseJobs.value[baseId]) { |
||||||
|
baseJobs.value[baseId] = jobs || baseJobs.value[baseId] |
||||||
|
} else { |
||||||
|
baseJobs.value[baseId] = jobs || [] |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
return { |
||||||
|
jobList, |
||||||
|
loadJobsForBase, |
||||||
|
getJobsForBase, |
||||||
|
} |
||||||
|
}) |
After Width: | Height: | Size: 36 KiB |
@ -0,0 +1,180 @@ |
|||||||
|
<script setup lang="ts"> |
||||||
|
import dayjs from 'dayjs' |
||||||
|
import { type ViewType, ViewTypes } from 'nocodb-sdk' |
||||||
|
|
||||||
|
const { $api, $poller } = useNuxtApp() |
||||||
|
|
||||||
|
const { appInfo } = useGlobal() |
||||||
|
|
||||||
|
const { extension, tables, fullscreen, getViewsForTable } = useExtensionHelperOrThrow() |
||||||
|
|
||||||
|
const { jobList, loadJobsForBase } = useJobs() |
||||||
|
|
||||||
|
const views = ref<ViewType[]>([]) |
||||||
|
|
||||||
|
const exportedFiles = computed(() => { |
||||||
|
return jobList.value |
||||||
|
.filter((job) => job.job === 'data-export') |
||||||
|
.map((job) => { |
||||||
|
return { |
||||||
|
...job, |
||||||
|
result: (job.result || {}) as { url: string; type: 'csv' | 'json' | 'xlsx'; title: string; timestamp: number }, |
||||||
|
} |
||||||
|
}) |
||||||
|
.sort((a, b) => dayjs(b.created_at).unix() - dayjs(a.created_at).unix()) |
||||||
|
}) |
||||||
|
|
||||||
|
const exportPayload = ref<{ |
||||||
|
tableId?: string |
||||||
|
viewId?: string |
||||||
|
}>({}) |
||||||
|
|
||||||
|
const tableList = computed(() => { |
||||||
|
return tables.value.map((table) => { |
||||||
|
return { |
||||||
|
label: table.title, |
||||||
|
value: table.id, |
||||||
|
} |
||||||
|
}) |
||||||
|
}) |
||||||
|
|
||||||
|
const viewList = computed(() => { |
||||||
|
if (!exportPayload.value.tableId) return [] |
||||||
|
return ( |
||||||
|
views.value |
||||||
|
.filter((view) => view.type === ViewTypes.GRID) |
||||||
|
.map((view) => { |
||||||
|
return { |
||||||
|
label: view.is_default ? `Default View` : view.title, |
||||||
|
value: view.id, |
||||||
|
} |
||||||
|
}) || [] |
||||||
|
) |
||||||
|
}) |
||||||
|
|
||||||
|
const reloadViews = async () => { |
||||||
|
if (exportPayload.value.tableId) { |
||||||
|
views.value = await getViewsForTable(exportPayload.value.tableId) |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
const onTableSelect = async (tableId: string) => { |
||||||
|
exportPayload.value.tableId = tableId |
||||||
|
await reloadViews() |
||||||
|
exportPayload.value.viewId = views.value.find((view) => view.is_default)?.id |
||||||
|
await extension.value.kvStore.set('exportPayload', exportPayload.value) |
||||||
|
} |
||||||
|
|
||||||
|
const onViewSelect = async (viewId: string) => { |
||||||
|
exportPayload.value.viewId = viewId |
||||||
|
await extension.value.kvStore.set('exportPayload', exportPayload.value) |
||||||
|
} |
||||||
|
|
||||||
|
const isExporting = ref(false) |
||||||
|
|
||||||
|
async function exportDataAsync() { |
||||||
|
try { |
||||||
|
if (isExporting.value || !exportPayload.value.viewId) return |
||||||
|
|
||||||
|
isExporting.value = true |
||||||
|
|
||||||
|
const jobData = await $api.export.data(exportPayload.value.viewId, 'csv', {}) |
||||||
|
|
||||||
|
jobList.value.unshift(jobData) |
||||||
|
|
||||||
|
$poller.subscribe( |
||||||
|
{ id: jobData.id }, |
||||||
|
async (data: { |
||||||
|
id: string |
||||||
|
status?: string |
||||||
|
data?: { |
||||||
|
error?: { |
||||||
|
message: string |
||||||
|
} |
||||||
|
message?: string |
||||||
|
result?: any |
||||||
|
} |
||||||
|
}) => { |
||||||
|
if (data.status !== 'close') { |
||||||
|
if (data.status === JobStatus.COMPLETED) { |
||||||
|
// Export completed successfully |
||||||
|
message.info('Successfully exported data!') |
||||||
|
|
||||||
|
const job = jobList.value.find((j) => j.id === data.id) |
||||||
|
if (job) { |
||||||
|
job.status = JobStatus.COMPLETED |
||||||
|
job.result = data.data?.result |
||||||
|
} |
||||||
|
|
||||||
|
isExporting.value = false |
||||||
|
} else if (data.status === JobStatus.FAILED) { |
||||||
|
message.error('Failed to export data!') |
||||||
|
|
||||||
|
const job = jobList.value.find((j) => j.id === data.id) |
||||||
|
if (job) { |
||||||
|
job.status = JobStatus.FAILED |
||||||
|
} |
||||||
|
|
||||||
|
isExporting.value = false |
||||||
|
} |
||||||
|
} |
||||||
|
}, |
||||||
|
) |
||||||
|
} catch (e: any) { |
||||||
|
message.error(await extractSdkResponseErrorMsg(e)) |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
const urlHelper = (url: string) => { |
||||||
|
if (url.startsWith('http')) { |
||||||
|
return url |
||||||
|
} else { |
||||||
|
return `${appInfo.value.ncSiteUrl || BASE_FALLBACK_URL}/${url}` |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
const titleHelper = () => { |
||||||
|
const table = tables.value.find((t) => t.id === exportPayload.value.tableId) |
||||||
|
const view = views.value.find((v) => v.id === exportPayload.value.viewId) |
||||||
|
|
||||||
|
return `${table?.title} (${view?.is_default ? 'Default View' : view?.title})` |
||||||
|
} |
||||||
|
|
||||||
|
onMounted(() => { |
||||||
|
exportPayload.value = extension.value.kvStore.get('exportPayload') || {} |
||||||
|
reloadViews() |
||||||
|
loadJobsForBase() |
||||||
|
}) |
||||||
|
</script> |
||||||
|
|
||||||
|
<template> |
||||||
|
<div class="flex flex-col gap-2 p-2"> |
||||||
|
<NcSelect v-model:value="exportPayload.tableId" :options="tableList" @disabled="isExporting" @change="onTableSelect" /> |
||||||
|
<NcSelect v-model:value="exportPayload.viewId" :options="viewList" @disabled="isExporting" @change="onViewSelect" /> |
||||||
|
<NcButton @loading="isExporting" @click="exportDataAsync">Export</NcButton> |
||||||
|
<div |
||||||
|
class="flex flex-col" |
||||||
|
:class="{ |
||||||
|
'max-h-[60px] overflow-auto': !fullscreen, |
||||||
|
}" |
||||||
|
> |
||||||
|
<div v-for="exp in exportedFiles" :key="exp.id" class="flex items-center gap-1"> |
||||||
|
<template v-if="exp.status === JobStatus.COMPLETED && exp.result"> |
||||||
|
<GeneralIcon icon="file" /> |
||||||
|
<div>{{ exp.result.title }}</div> |
||||||
|
<a :href="urlHelper(exp.result.url)" target="_blank">Download</a> |
||||||
|
</template> |
||||||
|
<template v-else-if="exp.status === JobStatus.FAILED"> |
||||||
|
<GeneralIcon icon="error" class="text-red-500" /> |
||||||
|
<div>{{ exp.result.title }}</div> |
||||||
|
</template> |
||||||
|
<template v-else> |
||||||
|
<GeneralLoader size="small" /> |
||||||
|
<div>{{ titleHelper() }}</div> |
||||||
|
</template> |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
</template> |
||||||
|
|
||||||
|
<style lang="scss"></style> |
@ -0,0 +1,11 @@ |
|||||||
|
{ |
||||||
|
"id": "nc-data-exporter", |
||||||
|
"title": "Data Exporter", |
||||||
|
"description": "Export any view in various formats", |
||||||
|
"entry": "data-exporter", |
||||||
|
"version": "0.1", |
||||||
|
"iconUrl": "data-exporter/icon.png", |
||||||
|
"publisherName": "NocoDB", |
||||||
|
"publisherEmail": "contact@nocodb.com", |
||||||
|
"publisherUrl": "https://www.nocodb.com" |
||||||
|
} |
@ -0,0 +1,21 @@ |
|||||||
|
import { Test } from '@nestjs/testing'; |
||||||
|
import { HooksService } from '../services/hooks.service'; |
||||||
|
import { JobsMetaController } from './jobs-meta.controller'; |
||||||
|
import type { TestingModule } from '@nestjs/testing'; |
||||||
|
|
||||||
|
describe('JobsMetaController', () => { |
||||||
|
let controller: JobsMetaController; |
||||||
|
|
||||||
|
beforeEach(async () => { |
||||||
|
const module: TestingModule = await Test.createTestingModule({ |
||||||
|
controllers: [JobsMetaController], |
||||||
|
providers: [HooksService], |
||||||
|
}).compile(); |
||||||
|
|
||||||
|
controller = module.get<JobsMetaController>(JobsMetaController); |
||||||
|
}); |
||||||
|
|
||||||
|
it('should be defined', () => { |
||||||
|
expect(controller).toBeDefined(); |
||||||
|
}); |
||||||
|
}); |
@ -0,0 +1,28 @@ |
|||||||
|
import { Body, Controller, Post, Req, UseGuards } from '@nestjs/common'; |
||||||
|
import type { JobStatus, JobTypes } from '~/interface/Jobs'; |
||||||
|
import { GlobalGuard } from '~/guards/global/global.guard'; |
||||||
|
import { Acl } from '~/middlewares/extract-ids/extract-ids.middleware'; |
||||||
|
import { MetaApiLimiterGuard } from '~/guards/meta-api-limiter.guard'; |
||||||
|
import { TenantContext } from '~/decorators/tenant-context.decorator'; |
||||||
|
import { NcContext, NcRequest } from '~/interface/config'; |
||||||
|
import { JobsMetaService } from '~/services/jobs-meta.service'; |
||||||
|
|
||||||
|
@Controller() |
||||||
|
@UseGuards(MetaApiLimiterGuard, GlobalGuard) |
||||||
|
export class JobsMetaController { |
||||||
|
constructor(private readonly jobsMetaService: JobsMetaService) {} |
||||||
|
|
||||||
|
@Post(['/api/v2/jobs/:baseId']) |
||||||
|
@Acl('jobList') |
||||||
|
async jobList( |
||||||
|
@TenantContext() context: NcContext, |
||||||
|
@Req() req: NcRequest, |
||||||
|
@Body() |
||||||
|
conditions?: { |
||||||
|
job?: JobTypes; |
||||||
|
status?: JobStatus; |
||||||
|
}, |
||||||
|
) { |
||||||
|
return await this.jobsMetaService.list(context, conditions, req); |
||||||
|
} |
||||||
|
} |
@ -0,0 +1,30 @@ |
|||||||
|
import type { Knex } from 'knex'; |
||||||
|
import { MetaTable } from '~/utils/globals'; |
||||||
|
|
||||||
|
const up = async (knex: Knex) => { |
||||||
|
await knex.schema.createTable(MetaTable.JOBS, (table) => { |
||||||
|
table.string('id', 20).primary(); |
||||||
|
|
||||||
|
table.string('job', 255); |
||||||
|
|
||||||
|
table.string('status', 20); |
||||||
|
|
||||||
|
table.text('result'); |
||||||
|
|
||||||
|
table.string('fk_user_id', 20); |
||||||
|
|
||||||
|
table.string('fk_workspace_id', 20); |
||||||
|
|
||||||
|
table.string('base_id', 20); |
||||||
|
|
||||||
|
table.timestamps(true, true); |
||||||
|
|
||||||
|
// TODO - add indexes
|
||||||
|
}); |
||||||
|
}; |
||||||
|
|
||||||
|
const down = async (knex: Knex) => { |
||||||
|
await knex.schema.dropTable(MetaTable.JOBS); |
||||||
|
}; |
||||||
|
|
||||||
|
export { up, down }; |
@ -0,0 +1,136 @@ |
|||||||
|
import type { NcContext } from '~/interface/config'; |
||||||
|
import type { Condition } from '~/db/CustomKnex'; |
||||||
|
import Noco from '~/Noco'; |
||||||
|
import { |
||||||
|
CacheDelDirection, |
||||||
|
CacheGetType, |
||||||
|
CacheScope, |
||||||
|
MetaTable, |
||||||
|
} from '~/utils/globals'; |
||||||
|
import NocoCache from '~/cache/NocoCache'; |
||||||
|
import { extractProps } from '~/helpers/extractProps'; |
||||||
|
import { prepareForDb, prepareForResponse } from '~/utils/modelUtils'; |
||||||
|
|
||||||
|
export default class Job { |
||||||
|
id: string; |
||||||
|
job: string; |
||||||
|
status: string; |
||||||
|
result: string; |
||||||
|
fk_user_id: string; |
||||||
|
fk_workspace_id: string; |
||||||
|
base_id: string; |
||||||
|
created_at: Date; |
||||||
|
updated_at: Date; |
||||||
|
|
||||||
|
constructor(data: Partial<Job>) { |
||||||
|
Object.assign(this, data); |
||||||
|
} |
||||||
|
|
||||||
|
public static async insert( |
||||||
|
context: NcContext, |
||||||
|
jobObj: Partial<Job>, |
||||||
|
ncMeta = Noco.ncMeta, |
||||||
|
) { |
||||||
|
const insertObj = extractProps(jobObj, [ |
||||||
|
'job', |
||||||
|
'status', |
||||||
|
'result', |
||||||
|
'fk_user_id', |
||||||
|
]); |
||||||
|
|
||||||
|
const { id } = await ncMeta.metaInsert2( |
||||||
|
context.workspace_id, |
||||||
|
context.base_id, |
||||||
|
MetaTable.JOBS, |
||||||
|
insertObj, |
||||||
|
); |
||||||
|
|
||||||
|
return this.get(context, id, ncMeta); |
||||||
|
} |
||||||
|
|
||||||
|
public static async update( |
||||||
|
context: NcContext, |
||||||
|
jobId: string, |
||||||
|
jobObj: Partial<Job>, |
||||||
|
ncMeta = Noco.ncMeta, |
||||||
|
) { |
||||||
|
const updateObj = extractProps(jobObj, ['status', 'result']); |
||||||
|
|
||||||
|
const res = await ncMeta.metaUpdate( |
||||||
|
context.workspace_id, |
||||||
|
context.base_id, |
||||||
|
MetaTable.JOBS, |
||||||
|
prepareForDb(updateObj, 'result'), |
||||||
|
jobId, |
||||||
|
); |
||||||
|
|
||||||
|
await NocoCache.update( |
||||||
|
`${CacheScope.JOBS}:${jobId}`, |
||||||
|
prepareForResponse(updateObj, 'result'), |
||||||
|
); |
||||||
|
|
||||||
|
return res; |
||||||
|
} |
||||||
|
|
||||||
|
public static async delete( |
||||||
|
context: NcContext, |
||||||
|
jobId: string, |
||||||
|
ncMeta = Noco.ncMeta, |
||||||
|
) { |
||||||
|
await ncMeta.metaDelete( |
||||||
|
context.workspace_id, |
||||||
|
context.base_id, |
||||||
|
MetaTable.JOBS, |
||||||
|
jobId, |
||||||
|
); |
||||||
|
|
||||||
|
await NocoCache.deepDel( |
||||||
|
`${CacheScope.JOBS}:${jobId}`, |
||||||
|
CacheDelDirection.CHILD_TO_PARENT, |
||||||
|
); |
||||||
|
} |
||||||
|
|
||||||
|
public static async get(context: NcContext, id: any, ncMeta = Noco.ncMeta) { |
||||||
|
let jobData = |
||||||
|
id && |
||||||
|
(await NocoCache.get( |
||||||
|
`${CacheScope.JOBS}:${id}`, |
||||||
|
CacheGetType.TYPE_OBJECT, |
||||||
|
)); |
||||||
|
|
||||||
|
if (!jobData) { |
||||||
|
jobData = await ncMeta.metaGet2( |
||||||
|
context.workspace_id, |
||||||
|
context.base_id, |
||||||
|
MetaTable.JOBS, |
||||||
|
id, |
||||||
|
); |
||||||
|
|
||||||
|
jobData = prepareForResponse(jobData, 'result'); |
||||||
|
|
||||||
|
await NocoCache.set(`${CacheScope.JOBS}:${id}`, jobData); |
||||||
|
} |
||||||
|
|
||||||
|
return jobData && new Job(jobData); |
||||||
|
} |
||||||
|
|
||||||
|
public static async list( |
||||||
|
context: NcContext, |
||||||
|
opts: { |
||||||
|
condition?: Record<string, string>; |
||||||
|
xcCondition?: Condition; |
||||||
|
}, |
||||||
|
ncMeta = Noco.ncMeta, |
||||||
|
): Promise<Job[]> { |
||||||
|
const jobList = await ncMeta.metaList2( |
||||||
|
context.workspace_id, |
||||||
|
context.base_id, |
||||||
|
MetaTable.JOBS, |
||||||
|
opts, |
||||||
|
); |
||||||
|
|
||||||
|
return jobList.map((job) => { |
||||||
|
return new Job(prepareForResponse(job, 'result')); |
||||||
|
}); |
||||||
|
} |
||||||
|
} |
@ -1,54 +0,0 @@ |
|||||||
import { |
|
||||||
OnQueueActive, |
|
||||||
OnQueueCompleted, |
|
||||||
OnQueueFailed, |
|
||||||
Processor, |
|
||||||
} from '@nestjs/bull'; |
|
||||||
import { Job } from 'bull'; |
|
||||||
import { EventEmitter2 } from '@nestjs/event-emitter'; |
|
||||||
import { Logger } from '@nestjs/common'; |
|
||||||
import { JobEvents, JOBS_QUEUE, JobStatus } from '~/interface/Jobs'; |
|
||||||
|
|
||||||
@Processor(JOBS_QUEUE) |
|
||||||
export class JobsEventService { |
|
||||||
protected logger = new Logger(JobsEventService.name); |
|
||||||
|
|
||||||
constructor(private eventEmitter: EventEmitter2) {} |
|
||||||
|
|
||||||
@OnQueueActive() |
|
||||||
onActive(job: Job) { |
|
||||||
this.eventEmitter.emit(JobEvents.STATUS, { |
|
||||||
id: job.id.toString(), |
|
||||||
status: JobStatus.ACTIVE, |
|
||||||
}); |
|
||||||
} |
|
||||||
|
|
||||||
@OnQueueFailed() |
|
||||||
onFailed(job: Job, error: Error) { |
|
||||||
this.logger.error( |
|
||||||
`---- !! JOB FAILED !! ----\nid:${job.id}\nerror:${error.name} (${error.message})\n\nstack: ${error.stack}`, |
|
||||||
); |
|
||||||
|
|
||||||
const newLocal = this; |
|
||||||
newLocal.eventEmitter.emit(JobEvents.STATUS, { |
|
||||||
id: job.id.toString(), |
|
||||||
status: JobStatus.FAILED, |
|
||||||
data: { |
|
||||||
error: { |
|
||||||
message: error?.message, |
|
||||||
}, |
|
||||||
}, |
|
||||||
}); |
|
||||||
} |
|
||||||
|
|
||||||
@OnQueueCompleted() |
|
||||||
onCompleted(job: Job, data: any) { |
|
||||||
this.eventEmitter.emit(JobEvents.STATUS, { |
|
||||||
id: job.id.toString(), |
|
||||||
status: JobStatus.COMPLETED, |
|
||||||
data: { |
|
||||||
result: data, |
|
||||||
}, |
|
||||||
}); |
|
||||||
} |
|
||||||
} |
|
@ -0,0 +1,108 @@ |
|||||||
|
import { |
||||||
|
OnQueueActive, |
||||||
|
OnQueueCompleted, |
||||||
|
OnQueueFailed, |
||||||
|
Processor, |
||||||
|
} from '@nestjs/bull'; |
||||||
|
import { Job as BullJob } from 'bull'; |
||||||
|
import { EventEmitter2 } from '@nestjs/event-emitter'; |
||||||
|
import { Logger } from '@nestjs/common'; |
||||||
|
import { JobEvents, JOBS_QUEUE, JobStatus } from '~/interface/Jobs'; |
||||||
|
import { Job } from '~/models'; |
||||||
|
import { RootScopes } from '~/utils/globals'; |
||||||
|
|
||||||
|
@Processor(JOBS_QUEUE) |
||||||
|
export class JobsEventService { |
||||||
|
protected logger = new Logger(JobsEventService.name); |
||||||
|
|
||||||
|
constructor(private eventEmitter: EventEmitter2) {} |
||||||
|
|
||||||
|
@OnQueueActive() |
||||||
|
onActive(job: BullJob) { |
||||||
|
Job.update( |
||||||
|
{ |
||||||
|
workspace_id: RootScopes.ROOT, |
||||||
|
base_id: RootScopes.ROOT, |
||||||
|
}, |
||||||
|
job.id.toString(), |
||||||
|
{ |
||||||
|
status: JobStatus.ACTIVE, |
||||||
|
}, |
||||||
|
) |
||||||
|
.then(() => { |
||||||
|
this.eventEmitter.emit(JobEvents.STATUS, { |
||||||
|
id: job.id.toString(), |
||||||
|
status: JobStatus.ACTIVE, |
||||||
|
}); |
||||||
|
}) |
||||||
|
.catch((error) => { |
||||||
|
this.logger.error( |
||||||
|
`Failed to update job (${job.id}) status to active: ${error.message}`, |
||||||
|
); |
||||||
|
}); |
||||||
|
} |
||||||
|
|
||||||
|
@OnQueueFailed() |
||||||
|
onFailed(job: BullJob, error: Error) { |
||||||
|
this.logger.error( |
||||||
|
`---- !! JOB FAILED !! ----\nid:${job.id}\nerror:${error.name} (${error.message})\n\nstack: ${error.stack}`, |
||||||
|
); |
||||||
|
|
||||||
|
Job.update( |
||||||
|
{ |
||||||
|
workspace_id: RootScopes.ROOT, |
||||||
|
base_id: RootScopes.ROOT, |
||||||
|
}, |
||||||
|
job.id.toString(), |
||||||
|
{ |
||||||
|
status: JobStatus.FAILED, |
||||||
|
}, |
||||||
|
) |
||||||
|
.then(() => { |
||||||
|
const newLocal = this; |
||||||
|
newLocal.eventEmitter.emit(JobEvents.STATUS, { |
||||||
|
id: job.id.toString(), |
||||||
|
status: JobStatus.FAILED, |
||||||
|
data: { |
||||||
|
error: { |
||||||
|
message: error?.message, |
||||||
|
}, |
||||||
|
}, |
||||||
|
}); |
||||||
|
}) |
||||||
|
.catch((error) => { |
||||||
|
this.logger.error( |
||||||
|
`Failed to update job (${job.id}) status to failed: ${error.message}`, |
||||||
|
); |
||||||
|
}); |
||||||
|
} |
||||||
|
|
||||||
|
@OnQueueCompleted() |
||||||
|
onCompleted(job: BullJob, data: any) { |
||||||
|
Job.update( |
||||||
|
{ |
||||||
|
workspace_id: RootScopes.ROOT, |
||||||
|
base_id: RootScopes.ROOT, |
||||||
|
}, |
||||||
|
job.id.toString(), |
||||||
|
{ |
||||||
|
status: JobStatus.COMPLETED, |
||||||
|
result: data, |
||||||
|
}, |
||||||
|
) |
||||||
|
.then(() => { |
||||||
|
this.eventEmitter.emit(JobEvents.STATUS, { |
||||||
|
id: job.id.toString(), |
||||||
|
status: JobStatus.COMPLETED, |
||||||
|
data: { |
||||||
|
result: data, |
||||||
|
}, |
||||||
|
}); |
||||||
|
}) |
||||||
|
.catch((error) => { |
||||||
|
this.logger.error( |
||||||
|
`Failed to update job (${job.id}) status to completed: ${error.message}`, |
||||||
|
); |
||||||
|
}); |
||||||
|
} |
||||||
|
} |
@ -0,0 +1,58 @@ |
|||||||
|
import { |
||||||
|
Body, |
||||||
|
Controller, |
||||||
|
HttpCode, |
||||||
|
Inject, |
||||||
|
Param, |
||||||
|
Post, |
||||||
|
Req, |
||||||
|
UseGuards, |
||||||
|
} from '@nestjs/common'; |
||||||
|
import type { DataExportJobData } from '~/interface/Jobs'; |
||||||
|
import { GlobalGuard } from '~/guards/global/global.guard'; |
||||||
|
import { Acl } from '~/middlewares/extract-ids/extract-ids.middleware'; |
||||||
|
import { BasesService } from '~/services/bases.service'; |
||||||
|
import { View } from '~/models'; |
||||||
|
import { JobTypes } from '~/interface/Jobs'; |
||||||
|
import { MetaApiLimiterGuard } from '~/guards/meta-api-limiter.guard'; |
||||||
|
import { IJobsService } from '~/modules/jobs/jobs-service.interface'; |
||||||
|
import { TenantContext } from '~/decorators/tenant-context.decorator'; |
||||||
|
import { NcContext, NcRequest } from '~/interface/config'; |
||||||
|
import { NcError } from '~/helpers/catchError'; |
||||||
|
|
||||||
|
@Controller() |
||||||
|
@UseGuards(MetaApiLimiterGuard, GlobalGuard) |
||||||
|
export class DataExportController { |
||||||
|
constructor( |
||||||
|
@Inject('JobsService') protected readonly jobsService: IJobsService, |
||||||
|
protected readonly basesService: BasesService, |
||||||
|
) {} |
||||||
|
|
||||||
|
@Post(['/api/v2/export/:viewId/:exportAs']) |
||||||
|
@HttpCode(200) |
||||||
|
// TODO add new ACL
|
||||||
|
@Acl('dataList') |
||||||
|
async exportModelData( |
||||||
|
@TenantContext() context: NcContext, |
||||||
|
@Req() req: NcRequest, |
||||||
|
@Param('viewId') viewId: string, |
||||||
|
@Param('exportAs') exportAs: 'csv' | 'json' | 'xlsx', |
||||||
|
@Body() options: DataExportJobData['options'], |
||||||
|
) { |
||||||
|
const view = await View.get(context, viewId); |
||||||
|
|
||||||
|
if (!view) NcError.viewNotFound(viewId); |
||||||
|
|
||||||
|
const job = await this.jobsService.add(JobTypes.DataExport, { |
||||||
|
context, |
||||||
|
options, |
||||||
|
modelId: view.fk_model_id, |
||||||
|
viewId, |
||||||
|
user: req.user, |
||||||
|
exportAs, |
||||||
|
ncSiteUrl: req.ncSiteUrl, |
||||||
|
}); |
||||||
|
|
||||||
|
return job; |
||||||
|
} |
||||||
|
} |
@ -0,0 +1,132 @@ |
|||||||
|
import { Readable } from 'stream'; |
||||||
|
import path from 'path'; |
||||||
|
import { Process, Processor } from '@nestjs/bull'; |
||||||
|
import { Logger } from '@nestjs/common'; |
||||||
|
import { Job } from 'bull'; |
||||||
|
import moment from 'moment'; |
||||||
|
import { type DataExportJobData, JOBS_QUEUE, JobTypes } from '~/interface/Jobs'; |
||||||
|
import { elapsedTime, initTime } from '~/modules/jobs/helpers'; |
||||||
|
import { ExportService } from '~/modules/jobs/jobs/export-import/export.service'; |
||||||
|
import { Model, PresignedUrl, View } from '~/models'; |
||||||
|
import { NcError } from '~/helpers/catchError'; |
||||||
|
import NcPluginMgrv2 from '~/helpers/NcPluginMgrv2'; |
||||||
|
|
||||||
|
function getViewTitle(view: View) { |
||||||
|
return view.is_default ? 'Default View' : view.title; |
||||||
|
} |
||||||
|
|
||||||
|
@Processor(JOBS_QUEUE) |
||||||
|
export class DataExportProcessor { |
||||||
|
private logger = new Logger(DataExportProcessor.name); |
||||||
|
|
||||||
|
constructor(private readonly exportService: ExportService) {} |
||||||
|
|
||||||
|
@Process(JobTypes.DataExport) |
||||||
|
async job(job: Job<DataExportJobData>) { |
||||||
|
const { |
||||||
|
context, |
||||||
|
options, |
||||||
|
modelId, |
||||||
|
viewId, |
||||||
|
user: _user, |
||||||
|
exportAs, |
||||||
|
ncSiteUrl, |
||||||
|
} = job.data; |
||||||
|
|
||||||
|
if (exportAs !== 'csv') NcError.notImplemented(`Export as ${exportAs}`); |
||||||
|
|
||||||
|
const hrTime = initTime(); |
||||||
|
|
||||||
|
const model = await Model.get(context, modelId); |
||||||
|
|
||||||
|
if (!model) NcError.tableNotFound(modelId); |
||||||
|
|
||||||
|
const view = await View.get(context, viewId); |
||||||
|
|
||||||
|
if (!view) NcError.viewNotFound(viewId); |
||||||
|
|
||||||
|
// date time as containing folder YYYY-MM-DD/HH
|
||||||
|
const dateFolder = moment().format('YYYY-MM-DD/HH'); |
||||||
|
|
||||||
|
const storageAdapter = await NcPluginMgrv2.storageAdapter(); |
||||||
|
|
||||||
|
const destPath = `nc/uploads/data-export/${dateFolder}/${modelId}/${ |
||||||
|
model.title |
||||||
|
} (${getViewTitle(view)}) - ${Date.now()}.csv`;
|
||||||
|
|
||||||
|
let url = null; |
||||||
|
|
||||||
|
try { |
||||||
|
const dataStream = new Readable({ |
||||||
|
read() {}, |
||||||
|
}); |
||||||
|
|
||||||
|
dataStream.setEncoding('utf8'); |
||||||
|
|
||||||
|
let error = null; |
||||||
|
|
||||||
|
const uploadFilePromise = (storageAdapter as any) |
||||||
|
.fileCreateByStream(destPath, dataStream) |
||||||
|
.catch((e) => { |
||||||
|
this.logger.error(e); |
||||||
|
error = e; |
||||||
|
}); |
||||||
|
|
||||||
|
this.exportService |
||||||
|
.streamModelDataAsCsv(context, { |
||||||
|
dataStream, |
||||||
|
linkStream: null, |
||||||
|
baseId: model.base_id, |
||||||
|
modelId: model.id, |
||||||
|
viewId: view.id, |
||||||
|
ncSiteUrl: ncSiteUrl, |
||||||
|
delimiter: options?.delimiter, |
||||||
|
}) |
||||||
|
.catch((e) => { |
||||||
|
this.logger.debug(e); |
||||||
|
dataStream.push(null); |
||||||
|
error = e; |
||||||
|
}); |
||||||
|
|
||||||
|
url = await uploadFilePromise; |
||||||
|
|
||||||
|
// if url is not defined, it is local attachment
|
||||||
|
if (!url) { |
||||||
|
url = await PresignedUrl.getSignedUrl({ |
||||||
|
path: path.join(destPath.replace('nc/uploads/', '')), |
||||||
|
filename: `${model.title} (${getViewTitle(view)}).csv`, |
||||||
|
expireSeconds: 3 * 60 * 60, // 3 hours
|
||||||
|
}); |
||||||
|
} else { |
||||||
|
if (url.includes('.amazonaws.com/')) { |
||||||
|
const relativePath = decodeURI(url.split('.amazonaws.com/')[1]); |
||||||
|
url = await PresignedUrl.getSignedUrl({ |
||||||
|
path: relativePath, |
||||||
|
filename: `${model.title} (${getViewTitle(view)}).csv`, |
||||||
|
s3: true, |
||||||
|
expireSeconds: 3 * 60 * 60, // 3 hours
|
||||||
|
}); |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
if (error) { |
||||||
|
throw error; |
||||||
|
} |
||||||
|
|
||||||
|
elapsedTime( |
||||||
|
hrTime, |
||||||
|
`exported data for model ${modelId} view ${viewId} as ${exportAs}`, |
||||||
|
'exportData', |
||||||
|
); |
||||||
|
} catch (e) { |
||||||
|
throw NcError.badRequest(e); |
||||||
|
} |
||||||
|
|
||||||
|
return { |
||||||
|
timestamp: new Date(), |
||||||
|
type: exportAs, |
||||||
|
title: `${model.title} (${getViewTitle(view)})`, |
||||||
|
url, |
||||||
|
}; |
||||||
|
} |
||||||
|
} |
@ -1,53 +0,0 @@ |
|||||||
import { |
|
||||||
OnQueueActive, |
|
||||||
OnQueueCompleted, |
|
||||||
OnQueueFailed, |
|
||||||
Processor, |
|
||||||
} from '@nestjs/bull'; |
|
||||||
import { Job } from 'bull'; |
|
||||||
import { EventEmitter2 } from '@nestjs/event-emitter'; |
|
||||||
import { Logger } from '@nestjs/common'; |
|
||||||
import { JobEvents, JOBS_QUEUE, JobStatus } from '~/interface/Jobs'; |
|
||||||
|
|
||||||
@Processor(JOBS_QUEUE) |
|
||||||
export class JobsEventService { |
|
||||||
protected logger = new Logger(JobsEventService.name); |
|
||||||
|
|
||||||
constructor(private eventEmitter: EventEmitter2) {} |
|
||||||
|
|
||||||
@OnQueueActive() |
|
||||||
onActive(job: Job) { |
|
||||||
this.eventEmitter.emit(JobEvents.STATUS, { |
|
||||||
id: job.id.toString(), |
|
||||||
status: JobStatus.ACTIVE, |
|
||||||
}); |
|
||||||
} |
|
||||||
|
|
||||||
@OnQueueFailed() |
|
||||||
onFailed(job: Job, error: Error) { |
|
||||||
this.logger.error( |
|
||||||
`---- !! JOB FAILED !! ----\nid:${job.id}\nerror:${error.name} (${error.message})\n\nstack: ${error.stack}`, |
|
||||||
); |
|
||||||
|
|
||||||
this.eventEmitter.emit(JobEvents.STATUS, { |
|
||||||
id: job.id.toString(), |
|
||||||
status: JobStatus.FAILED, |
|
||||||
data: { |
|
||||||
error: { |
|
||||||
message: error?.message, |
|
||||||
}, |
|
||||||
}, |
|
||||||
}); |
|
||||||
} |
|
||||||
|
|
||||||
@OnQueueCompleted() |
|
||||||
onCompleted(job: Job, data: any) { |
|
||||||
this.eventEmitter.emit(JobEvents.STATUS, { |
|
||||||
id: job.id.toString(), |
|
||||||
status: JobStatus.COMPLETED, |
|
||||||
data: { |
|
||||||
result: data, |
|
||||||
}, |
|
||||||
}); |
|
||||||
} |
|
||||||
} |
|
@ -0,0 +1,19 @@ |
|||||||
|
import { Test } from '@nestjs/testing'; |
||||||
|
import { JobsMetaService } from './jobs-meta.service'; |
||||||
|
import type { TestingModule } from '@nestjs/testing'; |
||||||
|
|
||||||
|
describe('JobsMetaService', () => { |
||||||
|
let service: JobsMetaService; |
||||||
|
|
||||||
|
beforeEach(async () => { |
||||||
|
const module: TestingModule = await Test.createTestingModule({ |
||||||
|
providers: [JobsMetaService], |
||||||
|
}).compile(); |
||||||
|
|
||||||
|
service = module.get<JobsMetaService>(JobsMetaService); |
||||||
|
}); |
||||||
|
|
||||||
|
it('should be defined', () => { |
||||||
|
expect(service).toBeDefined(); |
||||||
|
}); |
||||||
|
}); |
@ -0,0 +1,83 @@ |
|||||||
|
import { Injectable } from '@nestjs/common'; |
||||||
|
import dayjs from 'dayjs'; |
||||||
|
import type { NcContext, NcRequest } from '~/interface/config'; |
||||||
|
import type { JobTypes } from '~/interface/Jobs'; |
||||||
|
import { JobStatus } from '~/interface/Jobs'; |
||||||
|
import { Job } from '~/models'; |
||||||
|
import Noco from '~/Noco'; |
||||||
|
|
||||||
|
@Injectable() |
||||||
|
export class JobsMetaService { |
||||||
|
constructor() {} |
||||||
|
|
||||||
|
async list( |
||||||
|
context: NcContext, |
||||||
|
param: { job?: JobTypes; status?: JobStatus }, |
||||||
|
req: NcRequest, |
||||||
|
) { |
||||||
|
/* |
||||||
|
* List jobs for the current base. |
||||||
|
* If the job is not created by the current user, exclude the result. |
||||||
|
* List jobs updated in the last 1 hour or jobs that are still active(, waiting, or delayed). |
||||||
|
*/ |
||||||
|
return Job.list(context, { |
||||||
|
xcCondition: { |
||||||
|
_and: [ |
||||||
|
...(param.job |
||||||
|
? [ |
||||||
|
{ |
||||||
|
job: { |
||||||
|
eq: param.job, |
||||||
|
}, |
||||||
|
}, |
||||||
|
] |
||||||
|
: []), |
||||||
|
...(param.status |
||||||
|
? [ |
||||||
|
{ |
||||||
|
status: { |
||||||
|
eq: param.status, |
||||||
|
}, |
||||||
|
}, |
||||||
|
] |
||||||
|
: []), |
||||||
|
{ |
||||||
|
_or: [ |
||||||
|
{ |
||||||
|
updated_at: { |
||||||
|
gt: Noco.ncMeta.formatDateTime( |
||||||
|
dayjs().subtract(1, 'hour').toISOString(), |
||||||
|
), |
||||||
|
}, |
||||||
|
}, |
||||||
|
{ |
||||||
|
status: { |
||||||
|
eq: JobStatus.ACTIVE, |
||||||
|
}, |
||||||
|
}, |
||||||
|
{ |
||||||
|
status: { |
||||||
|
eq: JobStatus.WAITING, |
||||||
|
}, |
||||||
|
}, |
||||||
|
{ |
||||||
|
status: { |
||||||
|
eq: JobStatus.DELAYED, |
||||||
|
}, |
||||||
|
}, |
||||||
|
], |
||||||
|
}, |
||||||
|
], |
||||||
|
}, |
||||||
|
}).then((jobs) => { |
||||||
|
return jobs.map((job) => { |
||||||
|
if (job.fk_user_id === req.user.id) { |
||||||
|
return job; |
||||||
|
} else { |
||||||
|
const { result, ...rest } = job; |
||||||
|
return rest; |
||||||
|
} |
||||||
|
}); |
||||||
|
}); |
||||||
|
} |
||||||
|
} |
Loading…
Reference in new issue