From e17fa3e45866ba487a1a536f6eb0c04a4c4bc81c Mon Sep 17 00:00:00 2001 From: Jonatan Alama Date: Fri, 4 Jun 2021 06:07:08 +0000 Subject: [PATCH 1/2] add task maintenance service --- .../src/components/admin/AdminProjectTask.js | 8 + .../admin/AdminProjectTaskMaintenance.js | 237 ++++++++++++++++++ .../annotation/AnnotatedVideoSeekbar.js | 1 - packages/nota-client/src/lib/api.js | 14 ++ packages/nota-server/src/bin/worker | 4 + packages/nota-server/src/lib/notaJobQueue.js | 4 + .../src/lib/notaJobQueueProcessorFactory.js | 1 + packages/nota-server/src/models/jobTask.js | 6 +- packages/nota-server/src/models/task.js | 138 +++++++++- .../nota-server/src/models/taskTemplate.js | 2 +- packages/nota-server/src/routes.js | 5 + packages/nota-server/src/routes/tasks.js | 51 +++- .../src/services/taskMaintenance.js | 27 ++ 13 files changed, 492 insertions(+), 6 deletions(-) create mode 100644 packages/nota-client/src/components/admin/AdminProjectTaskMaintenance.js create mode 100644 packages/nota-server/src/services/taskMaintenance.js diff --git a/packages/nota-client/src/components/admin/AdminProjectTask.js b/packages/nota-client/src/components/admin/AdminProjectTask.js index 5eee68d..94be577 100644 --- a/packages/nota-client/src/components/admin/AdminProjectTask.js +++ b/packages/nota-client/src/components/admin/AdminProjectTask.js @@ -20,6 +20,7 @@ import Loading from "../Loading"; import AdminProjectTaskAssignment from "./AdminProjectTaskAssignment"; import AdminProjectTaskExport from "./AdminProjectTaskExport"; import AdminProjectTaskFetch from "./AdminProjectTaskFetch"; +import AdminProjectTaskMaintenance from "./AdminProjectTaskMaintenance"; export function AdminProjectTask({ resource, project, loading, doGet }) { const { task, assignableUsers } = resource || { @@ -240,6 +241,13 @@ export function AdminProjectTask({ resource, project, loading, doGet }) { fetchJobs={task.fetchJobs} reload={handleReload} /> +
+ ); } diff --git a/packages/nota-client/src/components/admin/AdminProjectTaskMaintenance.js b/packages/nota-client/src/components/admin/AdminProjectTaskMaintenance.js new file mode 100644 index 0000000..feb100b --- /dev/null +++ b/packages/nota-client/src/components/admin/AdminProjectTaskMaintenance.js @@ -0,0 +1,237 @@ +import React, { useRef } from "react"; +import { Button, Card, Col, ListGroup, Nav, Row, Modal } from "react-bootstrap"; +import { parseDate } from "../../lib/utils"; +import { performTaskMaintenance } from "../../lib/api"; +import { JobTask } from "../../lib/models"; +import useInputForm, { string } from "../../lib/useInputForm"; +import { Form } from "react-bootstrap"; + +function AdminProjectTaskMaintenance({ + projectId, + task, + fetchJobs = [], + reload +}) { + const [showNewMaintenanceModal, setShowNewMaintenanceModal] = React.useState( + false + ); + const annotationNamesSelect = useRef(); + + const handlePerformMaintenance = async function(values) { + const maintenance = { + type: "STATUS_RESET", + options: { + annotations: ["UNDO_ALL", "UNDO_BY_NAME"].includes(values.annotations), + annotationConditions: {}, + taskItems: ["UNDO_ONGOING", "UNDO_ALL"].includes(values.taskItems), + taskItemConditions: { + onlyWithOngoing: values.taskItems === "UNDO_ONGOING" + }, + taskAssignments: ["UNDO_ONGOING", "UNDO_ALL"].includes( + values.taskAssignments + ), + taskAssignmentConditions: { + onlyWithOngoing: values.taskAssignments === "UNDO_ONGOING" + } + } + }; + + if (values.annotations === "UNDO_BY_NAME") { + if (!values.annotationNames) { + return; + } + + maintenance.options.annotationConditions.name = values.annotationNames.split( + "," + ); + } + + performTaskMaintenance({ projectId, taskId: task.id, maintenance }); + setShowNewMaintenanceModal(false); + reload(); + }; + const handleClickNew = async function() { + setShowNewMaintenanceModal(true); + }; + const handleCloseModal = function() { + setShowNewMaintenanceModal(false); + }; + + const formSchema = { + annotations: string().required(), + annotationNames: string(), + taskItems: string().required(), + taskAssignments: string().required() + }; + const [ + { values, touched, errors }, + handleChange, + handleSubmit + ] = useInputForm(handlePerformMaintenance, formSchema, { + annotations: "NO_ACTION", + taskItems: "UNDO_ONGOING", + taskAssignments: "NO_ACTION", + annotationNames: "" + }); + const templateAnnotationNames = ( + task.template.template.annotations || [] + ).map(annotation => annotation.name); + + return ( + <> + + + + + + {fetchJobs.map(job => ( + + + {job.id} + {JobTask.TYPE_TEXT[job.type]} + {JobTask.STATUS_TEXT[job.status]} + {JSON.stringify(job.config.result)} + +
+ Created: {parseDate(job.createdAt)} +
+
+ + Started: {job.startedAt ? parseDate(job.startedAt) : "--"} + +
+
+ + Finished:{" "} + {job.finishedAt ? parseDate(job.finishedAt) : "--"} + +
+ +
+
+ ))} +
+
+ + + Task Maintenance + + +
+ + Annotation + + + + + + + Annotationは必須です + + + {values.annotations === "UNDO_BY_NAME" ? ( + + Annotation Name List + { + handleChange({ + target: { + name: evt.target.name, + value: [...evt.target.selectedOptions] + .map(option => option.value) + .join(",") + } + }); + }} + isInvalid={touched.annotationNames && errors.annotationNames} + > + {templateAnnotationNames.map(name => ( + + ))} + + + Annotationは必須です + + + ) : null} + + Task Item + + + + + + Task Itemは必須です + + + + Task Assignment + + + + + + + Task Assignmentは必須です + + +
+
+ + + + +
+ + ); +} + +export default AdminProjectTaskMaintenance; diff --git a/packages/nota-client/src/components/annotation/AnnotatedVideoSeekbar.js b/packages/nota-client/src/components/annotation/AnnotatedVideoSeekbar.js index a2cacda..0c488a5 100644 --- a/packages/nota-client/src/components/annotation/AnnotatedVideoSeekbar.js +++ b/packages/nota-client/src/components/annotation/AnnotatedVideoSeekbar.js @@ -128,7 +128,6 @@ const AnnotatedVideoSeekbar = function({ }; const handleSkip = function(frames) { - console.log(frames); setPlayUntil(+Infinity); skipFrames(frames); }; diff --git a/packages/nota-client/src/lib/api.js b/packages/nota-client/src/lib/api.js index d872d80..6978612 100644 --- a/packages/nota-client/src/lib/api.js +++ b/packages/nota-client/src/lib/api.js @@ -201,6 +201,20 @@ export async function refreshTaskItems({ projectId, taskId }) { ); } +export async function performTaskMaintenance({ + projectId, + taskId, + maintenance +}) { + return await fetch(`/api/projects/${projectId}/tasks/${taskId}/maintenance`, { + method: "post", + body: JSON.stringify(maintenance), + headers: new Headers({ + "content-type": "application/json" + }) + }); +} + export async function deleteTask({ projectId, taskId }) { return await fetch(`/api/projects/${projectId}/tasks/${taskId}`, { method: "delete", diff --git a/packages/nota-server/src/bin/worker b/packages/nota-server/src/bin/worker index cd0d186..024ad0b 100644 --- a/packages/nota-server/src/bin/worker +++ b/packages/nota-server/src/bin/worker @@ -17,6 +17,10 @@ notaJobQueues[JobTask.TASK_NAME.TASK_EXPORT].process( CONCURRENCY, `${__dirname}/../services/taskExport.js` ); +notaJobQueues[JobTask.TASK_NAME.TASK_MAINTENANCE].process( + CONCURRENCY, + `${__dirname}/../services/taskMaintenance.js` +); const REPEAT_EVERY = 1000 * 60; // 1 minute diff --git a/packages/nota-server/src/lib/notaJobQueue.js b/packages/nota-server/src/lib/notaJobQueue.js index 0bb5f9f..bd5c526 100644 --- a/packages/nota-server/src/lib/notaJobQueue.js +++ b/packages/nota-server/src/lib/notaJobQueue.js @@ -25,6 +25,10 @@ const queues = { [JobTask.TASK_NAME.TASK_EXPORT]: new Bull( "job-queue-task-export", queueOptions + ), + [JobTask.TASK_NAME.TASK_MAINTENANCE]: new Bull( + "job-queue-task-maintenance", + queueOptions ) }; logger.info({ logType: "service", message: "nota-job-service initialized" }); diff --git a/packages/nota-server/src/lib/notaJobQueueProcessorFactory.js b/packages/nota-server/src/lib/notaJobQueueProcessorFactory.js index 7340944..f66c7fd 100644 --- a/packages/nota-server/src/lib/notaJobQueueProcessorFactory.js +++ b/packages/nota-server/src/lib/notaJobQueueProcessorFactory.js @@ -23,6 +23,7 @@ module.exports = function(task, processor) { }); done(); } catch (error) { + logger.error(error); jobTask.status = JobTask.STATUS.ERROR; jobTask.finishedAt = new Date(); jobTask.config = { ...jobTask.config, error: error.message }; diff --git a/packages/nota-server/src/models/jobTask.js b/packages/nota-server/src/models/jobTask.js index 822cfb6..a50172e 100644 --- a/packages/nota-server/src/models/jobTask.js +++ b/packages/nota-server/src/models/jobTask.js @@ -6,12 +6,14 @@ module.exports = function(sequelize) { JobTask.TASK = { MEDIA_SOURCE_FETCH: 1, TASK_FETCH: 2, - TASK_EXPORT: 3 + TASK_EXPORT: 3, + TASK_MAINTENANCE: 4 }; JobTask.TASK_NAME = { MEDIA_SOURCE_FETCH: "MEDIA_SOURCE_FETCH", TASK_FETCH: "TASK_FETCH", - TASK_EXPORT: "TASK_EXPORT" + TASK_EXPORT: "TASK_EXPORT", + TASK_MAINTENANCE: "TASK_MAINTENANCE" }; JobTask.TYPE = { diff --git a/packages/nota-server/src/models/task.js b/packages/nota-server/src/models/task.js index 38913e5..f9644fa 100644 --- a/packages/nota-server/src/models/task.js +++ b/packages/nota-server/src/models/task.js @@ -1,4 +1,4 @@ -const { Model, DataTypes, Op } = require("sequelize"); +const { Model, DataTypes, Op, QueryTypes } = require("sequelize"); const datasource = require("../lib/datasource"); const parser = require("../lib/parser"); const { logger } = require("../lib/logger"); @@ -178,6 +178,128 @@ module.exports = function(sequelize) { } }; + Task.prototype.statusReset = async function(options = {}) { + const { + annotations = false, + taskItems = false, + taskAssignments = false, + annotationConditions = {}, + taskItemConditions = {}, + taskAssignmentConditions = {} + } = options; + + // Transaction + const transaction = await sequelize.transaction(); + const result = {}; + + try { + // Reset annotation status + if (annotations) { + const annotationName = Array.isArray(annotationConditions.name) + ? annotationConditions.name + : false; + const replacements = { taskId: this.id }; + + if (annotationName) { + replacements.annotationName = annotationName; + } + const [, annotationUpdatedCount] = await sequelize.query( + `UPDATE annotations +SET status=0 +WHERE task_item_id IN ( + SELECT id + FROM task_items + WHERE task_id=:taskId +) AND status=1 +${annotationName ? "AND labels_name IN (:annotationName)" : ""}`, + { + replacements, + type: QueryTypes.UPDATE, + transaction + } + ); + result.annotationUpdatedCount = annotationUpdatedCount; + } + + // Reset taskItem status + if (taskItems) { + const { onlyWithOngoing = false } = taskItemConditions; + const replacements = { taskId: this.id }; + const onlyOngoingSubquery = onlyWithOngoing + ? ` +AND id IN( + SELECT task_item_id + FROM annotations + WHERE task_item_id IN ( + SELECT task_item_id + FROM ( + SELECT id as task_item_id + FROM task_items + WHERE task_id=:taskId + ) AS tmp + ) AND status=0 +) +` + : ""; + + const [, taskItemsUpdatedCount] = await sequelize.query( + ` +UPDATE task_items +SET status=0 +WHERE task_id=:taskId +AND status=1 +${onlyOngoingSubquery}`, + { + replacements, + type: QueryTypes.UPDATE, + transaction + } + ); + result.taskItemsUpdatedCount = taskItemsUpdatedCount; + } + + // Reset taskAssignment status + if (taskAssignments) { + const { onlyWithOngoing = false } = taskAssignmentConditions; + const replacements = { taskId: this.id }; + const onlyOngoingSubquery = onlyWithOngoing + ? ` +AND id IN( + SELECT task_assignment_id + FROM task_items + WHERE task_id=:taskId + AND status=0 +) +` + : ""; + + const [, taskAssignmentsUpdatedCount] = await sequelize.query( + ` +UPDATE task_assignments +SET status=100 +WHERE task_id=:taskId +AND status=500 +${onlyOngoingSubquery}`, + { + replacements, + type: QueryTypes.UPDATE, + transaction + } + ); + result.taskAssignmentsUpdatedCount = taskAssignmentsUpdatedCount; + } + + // Commit + await transaction.commit(); + + // Return results + return result; + } catch (error) { + await transaction.rollback(); + throw error; + } + }; + Task.prototype.exportTask = async function(options) { const [name, archive, exportedCount] = await this.getArchive(options); @@ -299,6 +421,20 @@ module.exports = function(sequelize) { return fetchJobs; }; + Task.prototype.getLastMaintenanceJobs = async function() { + const fetchJobs = await sequelize.models.JobTask.findAll({ + where: { + projectId: this.projectId, + resourceId: this.id, + task: sequelize.models.JobTask.TASK.TASK_MAINTENANCE + }, + order: [["createdAt", "DESC"]], + limit: 10 + }); + + return fetchJobs; + }; + Task.prototype.softDelete = async function(user) { this.status = Task.STATUS.DELETED; this.updatedBy = user.id; diff --git a/packages/nota-server/src/models/taskTemplate.js b/packages/nota-server/src/models/taskTemplate.js index 9bdb469..434d700 100644 --- a/packages/nota-server/src/models/taskTemplate.js +++ b/packages/nota-server/src/models/taskTemplate.js @@ -60,7 +60,7 @@ module.exports = function(sequelize) { ] }); TaskTemplate.addScope("forTask", { - attributes: ["id", "name"] + attributes: ["id", "name", "template"] }); TaskTemplate.addScope("forReference", { attributes: ["id", "name"] diff --git a/packages/nota-server/src/routes.js b/packages/nota-server/src/routes.js index bf026f3..240e956 100644 --- a/packages/nota-server/src/routes.js +++ b/packages/nota-server/src/routes.js @@ -266,6 +266,11 @@ router.put( projectCanAdmin, tasksApi.updateTask ); +router.post( + "/projects/:projectId(\\d+)/tasks/:taskId(\\d+)/maintenance", + projectCanAdmin, + tasksApi.taskMaintenance +); router.post( "/projects/:projectId(\\d+)/tasks/:taskId(\\d+)/refreshTaskItems", projectCanAdmin, diff --git a/packages/nota-server/src/routes/tasks.js b/packages/nota-server/src/routes/tasks.js index bbaf720..f91ee49 100644 --- a/packages/nota-server/src/routes/tasks.js +++ b/packages/nota-server/src/routes/tasks.js @@ -40,6 +40,7 @@ const taskResponseTemplate = function(task) { : undefined, exportJobs: task.exportJobs, fetchJobs: task.fetchJobs, + maintenanceJobs: task.maintenanceJobs, done: parseInt(task.get("done")), total: parseInt(task.get("total")), assignable: @@ -87,6 +88,8 @@ const getTask = async function(req, res, next) { task.exportJobs = exportJobs; const fetchJobs = await task.getLastFetchJobs(); task.fetchJobs = fetchJobs; + const maintenanceJobs = await task.getLastMaintenanceJobs(); + task.maintenanceJobs = maintenanceJobs; res.locals.response = task; res.locals.responseTemplate = taskResponseTemplate; @@ -230,6 +233,51 @@ const refreshTaskItems = async function(req, res, next) { } }; +/** + * req.body + * // taskItems/annotations status reset + * { + * type: "STATUS_RESET", + * options: { + * annotations?: boolean, + * taskItems?: boolean, + * taskAssignments?: boolean, + * annotationConditions?: { + * name?: string[] + * }, + * taskItemConditions?: { + * onlyWithOngoing?: boolean + * }, + * taskAssignmentConditions?: { + * onlyWithOngoing?: boolean + * } + * } + * } + */ +const taskMaintenance = async function(req, res, next) { + const { type, options } = req.body; + + if (!type || !options) { + const error = new Error("Invalid"); + error.status = 400; + next(error); + return; + } + + try { + await notaJobQueue.add(JobTask.TASK_NAME.TASK_MAINTENANCE, { + projectId: res.locals.project.id, + resourceId: res.locals.task.id, + data: { type, options }, + userId: req.user.id + }); + + res.sendStatus(204); + } catch (err) { + next(err); + } +}; + const exportTask = async function(req, res, next) { try { const { @@ -288,5 +336,6 @@ module.exports = { deleteTask, exportTask, downloadTask, - refreshTaskItems + refreshTaskItems, + taskMaintenance }; diff --git a/packages/nota-server/src/services/taskMaintenance.js b/packages/nota-server/src/services/taskMaintenance.js new file mode 100644 index 0000000..3cb48c9 --- /dev/null +++ b/packages/nota-server/src/services/taskMaintenance.js @@ -0,0 +1,27 @@ +const processorFactory = require("../lib/notaJobQueueProcessorFactory"); +const { Task, JobTask } = require("../models"); + +const processor = processorFactory( + JobTask.TASK_NAME.TASK_MAINTENANCE, + async function(jobTask, done) { + const taskId = jobTask.resourceId; + const data = jobTask.config.data; + const userId = jobTask.createdBy; + + if (!taskId || !userId || !data) { + throw new Error("taskId and userId are required"); + } + const task = await Task.findByPk(taskId); + let result; + + if (data.type === "STATUS_RESET") { + result = await task.statusReset(data.options); + } else { + throw new Error(`unsuported maintenance type: ${data.type}`); + } + + done(result); + } +); + +module.exports = processor; From cb60430c2ee18185c2f467c1db89ed6137072e7d Mon Sep 17 00:00:00 2001 From: Jonatan Alama Date: Sun, 4 Jul 2021 23:25:20 +0000 Subject: [PATCH 2/2] reset seekbar component on video change --- packages/nota-client/src/components/annotation/AnnotatedVideo.js | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/nota-client/src/components/annotation/AnnotatedVideo.js b/packages/nota-client/src/components/annotation/AnnotatedVideo.js index a105312..a5bfb47 100644 --- a/packages/nota-client/src/components/annotation/AnnotatedVideo.js +++ b/packages/nota-client/src/components/annotation/AnnotatedVideo.js @@ -64,6 +64,7 @@ const AnnotatedVideo = function({