mirror of https://github.com/nocodb/nocodb
mertmit
1 year ago
18 changed files with 679 additions and 427 deletions
@ -1,102 +0,0 @@
|
||||
import type { Socket } from 'socket.io-client' |
||||
import io from 'socket.io-client' |
||||
import { JobStatus, defineNuxtPlugin, useGlobal, watch } from '#imports' |
||||
|
||||
export default defineNuxtPlugin(async (nuxtApp) => { |
||||
const { appInfo } = useGlobal() |
||||
|
||||
let socket: Socket | null = null |
||||
let messageIndex = 0 |
||||
|
||||
const init = async (token: string) => { |
||||
try { |
||||
if (socket) socket.disconnect() |
||||
|
||||
const url = new URL(appInfo.value.ncSiteUrl, window.location.href.split(/[?#]/)[0]) |
||||
let socketPath = url.pathname |
||||
socketPath += socketPath.endsWith('/') ? 'socket.io' : '/socket.io' |
||||
|
||||
socket = io(`${url.href}jobs`, { |
||||
extraHeaders: { 'xc-auth': token }, |
||||
path: socketPath, |
||||
}) |
||||
|
||||
socket.on('connect_error', (e) => { |
||||
console.error(e) |
||||
socket?.disconnect() |
||||
}) |
||||
} catch {} |
||||
} |
||||
|
||||
if (nuxtApp.$state.signedIn.value) { |
||||
await init(nuxtApp.$state.token.value) |
||||
} |
||||
|
||||
const send = (evt: string, data: any) => { |
||||
if (socket) { |
||||
const _id = messageIndex++ |
||||
socket.emit(evt, { _id, data }) |
||||
return _id |
||||
} |
||||
} |
||||
|
||||
const jobs = { |
||||
subscribe( |
||||
job: { id: string } | any, |
||||
subscribedCb?: () => void, |
||||
statusCb?: (status: JobStatus, data?: any) => void, |
||||
logCb?: (data: { message: string }) => void, |
||||
) { |
||||
const logFn = (data: { id: string; data: { message: string } }) => { |
||||
if (data.id === job.id) { |
||||
if (logCb) logCb(data.data) |
||||
} |
||||
} |
||||
const statusFn = (data: any) => { |
||||
if (data.id === job.id) { |
||||
if (statusCb) statusCb(data.status, data.data) |
||||
if (data.status === JobStatus.COMPLETED || data.status === JobStatus.FAILED) { |
||||
socket?.off('status', statusFn) |
||||
socket?.off('log', logFn) |
||||
} |
||||
} |
||||
} |
||||
|
||||
const _id = send('subscribe', job) |
||||
|
||||
const subscribeFn = (data: { _id: number; id: string }) => { |
||||
if (data._id === _id) { |
||||
if (data.id !== job.id) { |
||||
job.id = data.id |
||||
} |
||||
if (subscribedCb) subscribedCb() |
||||
socket?.on('log', logFn) |
||||
socket?.on('status', statusFn) |
||||
socket?.off('subscribed', subscribeFn) |
||||
} |
||||
} |
||||
socket?.on('subscribed', subscribeFn) |
||||
}, |
||||
getStatus(id: string): Promise<string> { |
||||
return new Promise((resolve) => { |
||||
if (socket) { |
||||
const _id = send('status', { id }) |
||||
const tempFn = (data: any) => { |
||||
if (data._id === _id) { |
||||
resolve(data.status) |
||||
socket?.off('status', tempFn) |
||||
} |
||||
} |
||||
socket.on('status', tempFn) |
||||
} |
||||
}) |
||||
}, |
||||
} |
||||
|
||||
watch((nuxtApp.$state as ReturnType<typeof useGlobal>).token, (newToken, oldToken) => { |
||||
if (newToken && newToken !== oldToken) init(newToken) |
||||
else if (!newToken) socket?.disconnect() |
||||
}) |
||||
|
||||
nuxtApp.provide('jobs', jobs) |
||||
}) |
@ -0,0 +1,92 @@
|
||||
import type { Api as BaseAPI } from 'nocodb-sdk' |
||||
import { defineNuxtPlugin } from '#imports' |
||||
|
||||
export default defineNuxtPlugin(async (nuxtApp) => { |
||||
const api: BaseAPI<any> = nuxtApp.$api as any |
||||
|
||||
// unsubscribe all if signed out
|
||||
let unsub = false |
||||
|
||||
const subscribe = async ( |
||||
topic: { id: string } | any, |
||||
cb: (data: { |
||||
id: string |
||||
status?: string |
||||
data?: { |
||||
error?: { |
||||
message: string |
||||
} |
||||
message?: string |
||||
result?: any |
||||
} |
||||
}) => void, |
||||
_mid = 0, |
||||
) => { |
||||
if (unsub) return |
||||
|
||||
try { |
||||
const response: |
||||
| { |
||||
_mid: number |
||||
id: string |
||||
status: 'refresh' | 'update' | 'close' |
||||
data: any |
||||
} |
||||
| { |
||||
_mid: number |
||||
id: string |
||||
status: 'refresh' | 'update' | 'close' |
||||
data: any |
||||
}[] = await api.jobs.listen({ _mid, data: topic }) |
||||
|
||||
if (Array.isArray(response)) { |
||||
let lastMid = 0 |
||||
for (const r of response) { |
||||
if (r.status === 'close') { |
||||
return cb(r) |
||||
} else { |
||||
if (r.status === 'update') { |
||||
cb(r.data) |
||||
} |
||||
lastMid = r._mid |
||||
} |
||||
} |
||||
await subscribe(topic, cb, lastMid) |
||||
} else { |
||||
if (response.status === 'close') { |
||||
return cb(response) |
||||
} else if (response.status === 'update') { |
||||
cb(response.data) |
||||
await subscribe(topic, cb, response._mid) |
||||
} else if (response.status === 'refresh') { |
||||
await subscribe(topic, cb, _mid) |
||||
} |
||||
} |
||||
} catch (e) { |
||||
setTimeout(() => { |
||||
subscribe(topic, cb, _mid) |
||||
}, 1000) |
||||
} |
||||
} |
||||
|
||||
const init = () => { |
||||
unsub = false |
||||
} |
||||
|
||||
if ((nuxtApp.$state as ReturnType<typeof useGlobal>).signedIn.value) { |
||||
await init() |
||||
} |
||||
|
||||
watch((nuxtApp.$state as ReturnType<typeof useGlobal>).token, (newToken, oldToken) => { |
||||
if (newToken && newToken !== oldToken) init() |
||||
else if (!newToken) { |
||||
unsub = true |
||||
} |
||||
}) |
||||
|
||||
const poller = { |
||||
subscribe, |
||||
} |
||||
|
||||
nuxtApp.provide('poller', poller) |
||||
}) |
@ -0,0 +1,266 @@
|
||||
import { |
||||
Body, |
||||
Controller, |
||||
HttpCode, |
||||
Inject, |
||||
Post, |
||||
Request, |
||||
Response, |
||||
UseGuards, |
||||
} from '@nestjs/common'; |
||||
import { OnEvent } from '@nestjs/event-emitter'; |
||||
import { customAlphabet } from 'nanoid'; |
||||
import { JobsRedisService } from './redis/jobs-redis.service'; |
||||
import { JobStatus } from '~/interface/Jobs'; |
||||
import { JobEvents } from '~/interface/Jobs'; |
||||
import { GlobalGuard } from '~/guards/global/global.guard'; |
||||
import NocoCache from '~/cache/NocoCache'; |
||||
import { CacheDelDirection, CacheGetType, CacheScope } from '~/utils/globals'; |
||||
|
||||
const nanoidv2 = customAlphabet('1234567890abcdefghijklmnopqrstuvwxyz', 14); |
||||
const POLLING_INTERVAL = 10000; |
||||
|
||||
@Controller() |
||||
@UseGuards(GlobalGuard) |
||||
export class JobsController { |
||||
constructor( |
||||
@Inject('JobsService') private readonly jobsService, |
||||
private readonly jobsRedisService: JobsRedisService, |
||||
) {} |
||||
|
||||
private jobRooms = {}; |
||||
private localJobs = {}; |
||||
private closedJobs = []; |
||||
|
||||
@Post('/jobs/listen') |
||||
@HttpCode(200) |
||||
async listen( |
||||
@Response() res, |
||||
@Request() req, |
||||
@Body() body: { _mid: number; data: { id: string } }, |
||||
) { |
||||
const { _mid = 0, data } = body; |
||||
|
||||
const jobId = data.id; |
||||
|
||||
res.setHeader('Cache-Control', 'no-cache, must-revalidate'); |
||||
res.resId = nanoidv2(); |
||||
|
||||
let messages; |
||||
|
||||
if (this.localJobs[jobId]) { |
||||
messages = this.localJobs[jobId].messages; |
||||
} else { |
||||
messages = ( |
||||
await NocoCache.get( |
||||
`${CacheScope.JOBS}:${jobId}:messages`, |
||||
CacheGetType.TYPE_OBJECT, |
||||
) |
||||
)?.messages; |
||||
} |
||||
|
||||
const newMessages: any[] = []; |
||||
|
||||
if (messages) { |
||||
messages.forEach((m) => { |
||||
if (m._mid > _mid) { |
||||
newMessages.push(m); |
||||
} |
||||
}); |
||||
} |
||||
|
||||
if (newMessages.length > 0) { |
||||
res.send(newMessages); |
||||
return; |
||||
} |
||||
|
||||
if (this.closedJobs.includes(jobId)) { |
||||
res.send({ |
||||
status: 'close', |
||||
}); |
||||
return; |
||||
} |
||||
|
||||
if (this.jobRooms[jobId]) { |
||||
this.jobRooms[jobId].listeners.push(res); |
||||
} else { |
||||
this.jobRooms[jobId] = { |
||||
listeners: [res], |
||||
}; |
||||
// subscribe to job events
|
||||
this.jobsRedisService.subscribe(jobId, (data) => { |
||||
if (this.jobRooms[jobId]) { |
||||
this.jobRooms[jobId].listeners.forEach((res) => { |
||||
if (!res.headersSent) { |
||||
res.send({ |
||||
status: 'refresh', |
||||
}); |
||||
} |
||||
}); |
||||
} |
||||
|
||||
const cmd = data.cmd; |
||||
delete data.cmd; |
||||
switch (cmd) { |
||||
case JobEvents.STATUS: |
||||
if ([JobStatus.COMPLETED, JobStatus.FAILED].includes(data.status)) { |
||||
this.jobsRedisService.unsubscribe(jobId); |
||||
delete this.jobRooms[jobId]; |
||||
this.closedJobs.push(jobId); |
||||
setTimeout(() => { |
||||
this.closedJobs = this.closedJobs.filter((j) => j !== jobId); |
||||
}, POLLING_INTERVAL * 2); |
||||
} |
||||
break; |
||||
} |
||||
}); |
||||
} |
||||
|
||||
res.on('close', () => { |
||||
if (jobId && this.jobRooms[jobId]?.listeners) { |
||||
this.jobRooms[jobId].listeners = this.jobRooms[jobId].listeners.filter( |
||||
(r) => r.resId !== res.resId, |
||||
); |
||||
} |
||||
}); |
||||
|
||||
setTimeout(() => { |
||||
if (!res.headersSent) { |
||||
res.send({ |
||||
status: 'refresh', |
||||
}); |
||||
} |
||||
}, POLLING_INTERVAL); |
||||
} |
||||
|
||||
@Post('/jobs/status') |
||||
async status(@Body() data: { id: string } | any) { |
||||
let res: { |
||||
id?: string; |
||||
status?: JobStatus; |
||||
} | null = null; |
||||
if (Object.keys(data).every((k) => ['id'].includes(k)) && data?.id) { |
||||
const rooms = (await this.jobsService.jobList()).map( |
||||
(j) => `jobs-${j.id}`, |
||||
); |
||||
const room = rooms.find((r) => r === `jobs-${data.id}`); |
||||
if (room) { |
||||
res.id = data.id; |
||||
} |
||||
} else { |
||||
const job = await this.jobsService.getJobWithData(data); |
||||
if (job) { |
||||
res = {}; |
||||
res.id = job.id; |
||||
res.status = await this.jobsService.jobStatus(data.id); |
||||
} |
||||
} |
||||
|
||||
return res; |
||||
} |
||||
|
||||
@OnEvent(JobEvents.STATUS) |
||||
sendJobStatus(data: { id: string; status: JobStatus; data?: any }): void { |
||||
let response; |
||||
|
||||
const jobId = data.id; |
||||
|
||||
if (this.localJobs[jobId]) { |
||||
response = { |
||||
status: 'update', |
||||
data, |
||||
_mid: this.localJobs[jobId].messages.length + 1, |
||||
}; |
||||
this.localJobs[jobId].messages.push(response); |
||||
NocoCache.set(`${CacheScope.JOBS}:${jobId}:messages`, { |
||||
messages: this.localJobs[jobId].messages, |
||||
}); |
||||
} else { |
||||
response = { |
||||
status: 'update', |
||||
data, |
||||
_mid: 1, |
||||
}; |
||||
this.localJobs[jobId] = { |
||||
messages: [response], |
||||
}; |
||||
NocoCache.set(`${CacheScope.JOBS}:${jobId}:messages`, { |
||||
messages: this.localJobs[jobId].messages, |
||||
}); |
||||
} |
||||
|
||||
if (this.jobRooms[jobId]) { |
||||
this.jobRooms[jobId].listeners.forEach((res) => { |
||||
if (!res.headersSent) { |
||||
res.send(response); |
||||
} |
||||
}); |
||||
} |
||||
|
||||
if (process.env.NC_WORKER_CONTAINER === 'true') { |
||||
this.jobsRedisService.publish(jobId, { |
||||
cmd: JobEvents.STATUS, |
||||
...data, |
||||
}); |
||||
} |
||||
|
||||
if ([JobStatus.COMPLETED, JobStatus.FAILED].includes(data.status)) { |
||||
this.closedJobs.push(jobId); |
||||
setTimeout(() => { |
||||
this.closedJobs = this.closedJobs.filter((j) => j !== jobId); |
||||
}, POLLING_INTERVAL * 2); |
||||
|
||||
setTimeout(() => { |
||||
delete this.jobRooms[jobId]; |
||||
delete this.localJobs[jobId]; |
||||
NocoCache.deepDel(`jobs`, jobId, CacheDelDirection.CHILD_TO_PARENT); |
||||
}, POLLING_INTERVAL); |
||||
} |
||||
} |
||||
|
||||
@OnEvent(JobEvents.LOG) |
||||
sendJobLog(data: { id: string; data: { message: string } }): void { |
||||
let response; |
||||
|
||||
const jobId = data.id; |
||||
|
||||
if (this.localJobs[jobId]) { |
||||
response = { |
||||
status: 'update', |
||||
data, |
||||
_mid: this.localJobs[jobId].messages.length + 1, |
||||
}; |
||||
this.localJobs[jobId].messages.push(response); |
||||
NocoCache.set(`${CacheScope.JOBS}:${jobId}:messages`, { |
||||
messages: this.localJobs[jobId].messages, |
||||
}); |
||||
} else { |
||||
response = { |
||||
status: 'update', |
||||
data, |
||||
_mid: 1, |
||||
}; |
||||
this.localJobs[jobId] = { |
||||
messages: [response], |
||||
}; |
||||
NocoCache.set(`${CacheScope.JOBS}:${jobId}:messages`, { |
||||
messages: this.localJobs[jobId].messages, |
||||
}); |
||||
} |
||||
|
||||
if (this.jobRooms[jobId]) { |
||||
this.jobRooms[jobId].listeners.forEach((res) => { |
||||
if (!res.headersSent) { |
||||
res.send(response); |
||||
} |
||||
}); |
||||
} |
||||
|
||||
if (process.env.NC_WORKER_CONTAINER === 'true') { |
||||
this.jobsRedisService.publish(jobId, { |
||||
cmd: JobEvents.LOG, |
||||
...data, |
||||
}); |
||||
} |
||||
} |
||||
} |
@ -1,110 +0,0 @@
|
||||
import { |
||||
ConnectedSocket, |
||||
MessageBody, |
||||
SubscribeMessage, |
||||
WebSocketGateway, |
||||
WebSocketServer, |
||||
} from '@nestjs/websockets'; |
||||
import { Server, Socket } from 'socket.io'; |
||||
import { ExecutionContextHost } from '@nestjs/core/helpers/execution-context-host'; |
||||
import { AuthGuard } from '@nestjs/passport'; |
||||
import { OnEvent } from '@nestjs/event-emitter'; |
||||
import { Inject } from '@nestjs/common'; |
||||
import type { OnModuleInit } from '@nestjs/common'; |
||||
import type { JobStatus } from '~/interface/Jobs'; |
||||
import { JobEvents } from '~/interface/Jobs'; |
||||
|
||||
const url = new URL( |
||||
process.env.NC_PUBLIC_URL || |
||||
`http://localhost:${process.env.PORT || '8080'}/`, |
||||
); |
||||
let namespace = url.pathname; |
||||
namespace += namespace.endsWith('/') ? 'jobs' : '/jobs'; |
||||
|
||||
@WebSocketGateway({ |
||||
cors: { |
||||
origin: '*', |
||||
allowedHeaders: ['xc-auth'], |
||||
credentials: true, |
||||
}, |
||||
namespace, |
||||
}) |
||||
export class JobsGateway implements OnModuleInit { |
||||
constructor(@Inject('JobsService') private readonly jobsService) {} |
||||
|
||||
@WebSocketServer() |
||||
server: Server; |
||||
|
||||
async onModuleInit() { |
||||
this.server.use(async (socket, next) => { |
||||
try { |
||||
const context = new ExecutionContextHost([socket.handshake as any]); |
||||
const guard = new (AuthGuard('jwt'))(context); |
||||
await guard.canActivate(context); |
||||
} catch {} |
||||
|
||||
next(); |
||||
}); |
||||
} |
||||
|
||||
@SubscribeMessage('subscribe') |
||||
async subscribe( |
||||
@MessageBody() |
||||
body: { _id: number; data: { id: string } | any }, |
||||
@ConnectedSocket() client: Socket, |
||||
): Promise<void> { |
||||
const { _id, data } = body; |
||||
if (Object.keys(data).every((k) => ['id'].includes(k)) && data?.id) { |
||||
const rooms = (await this.jobsService.jobList()).map( |
||||
(j) => `jobs-${j.id}`, |
||||
); |
||||
const room = rooms.find((r) => r === `jobs-${data.id}`); |
||||
if (room) { |
||||
client.join(`jobs-${data.id}`); |
||||
client.emit('subscribed', { |
||||
_id, |
||||
id: data.id, |
||||
}); |
||||
} |
||||
} else { |
||||
const job = await this.jobsService.getJobWithData(data); |
||||
if (job) { |
||||
client.join(`jobs-${job.id}`); |
||||
client.emit('subscribed', { |
||||
_id, |
||||
id: job.id, |
||||
}); |
||||
} |
||||
} |
||||
} |
||||
|
||||
@SubscribeMessage('status') |
||||
async status( |
||||
@MessageBody() body: { _id: number; data: { id: string } }, |
||||
@ConnectedSocket() client: Socket, |
||||
): Promise<void> { |
||||
const { _id, data } = body; |
||||
client.emit('status', { |
||||
_id, |
||||
id: data.id, |
||||
status: await this.jobsService.jobStatus(data.id), |
||||
}); |
||||
} |
||||
|
||||
@OnEvent(JobEvents.STATUS) |
||||
sendJobStatus(data: { id: string; status: JobStatus; data?: any }): void { |
||||
this.server.to(`jobs-${data.id}`).emit('status', { |
||||
id: data.id, |
||||
status: data.status, |
||||
data: data.data, |
||||
}); |
||||
} |
||||
|
||||
@OnEvent(JobEvents.LOG) |
||||
sendJobLog(data: { id: string; data: { message: string } }): void { |
||||
this.server.to(`jobs-${data.id}`).emit('log', { |
||||
id: data.id, |
||||
data: data.data, |
||||
}); |
||||
} |
||||
} |
Loading…
Reference in new issue