diff --git a/client/package.json b/client/package.json index 51964d596..a8b28f767 100644 --- a/client/package.json +++ b/client/package.json @@ -69,6 +69,7 @@ "@types/mime-types": "^2.1.0", "@types/mock-fs": "^4.13.0", "@types/node": "^14.0.5", + "@types/proper-lockfile": "^4.1.1", "@types/pump": "^1.1.0", "@types/range-parser": "^1.2.3", "@types/request": "^2.48.5", @@ -102,6 +103,7 @@ "jest-transform-stub": "^2.0.0", "mime-types": "^2.1.27", "mock-fs": "^4.13.0", + "proper-lockfile": "^4.1.1", "pump": "^3.0.0", "range-parser": "^1.2.1", "request": "^2.88.2", diff --git a/client/platform/desktop/backend/native/common.ts b/client/platform/desktop/backend/native/common.ts index fbb15888e..16811f73a 100644 --- a/client/platform/desktop/backend/native/common.ts +++ b/client/platform/desktop/backend/native/common.ts @@ -6,6 +6,7 @@ import fs from 'fs-extra'; import { shell } from 'electron'; import mime from 'mime-types'; import moment from 'moment'; +import lockfile from 'proper-lockfile'; import { DatasetType, MultiTrackRecord, Pipelines, SaveDetectionsArgs, FrameImage, DatasetMetaMutable, } from 'viame-web-common/apispec'; @@ -26,6 +27,13 @@ const JsonTrackFileName = /^result(_.*)?\.json$/; const JsonMetaFileName = 'meta.json'; const CsvFileName = /^.*\.csv$/; +async function acquireDirLock(dir: string) { + const release = await lockfile.lock(dir, { + lockfilePath: npath.join(dir, 'dir.lock'), + }); + return release; +} + /** * locate json track file in a directory * @param path path to a directory @@ -214,7 +222,7 @@ async function createKwiverRunWorkingDir( // eslint won't recognize \. as valid escape // eslint-disable-next-line no-useless-escape const safeDatasetName = jsonMetaList[0].name.replace(/[\.\s/]+/g, '_'); - const runFolderName = moment().format(`[${safeDatasetName}_${pipeline}]_MM-DD-yy_hh-mm-ss`); + const runFolderName = moment().format(`[${safeDatasetName}_${pipeline}]_MM-DD-yy_hh-mm-ss.SSS`); const runFolderPath = npath.join(jobFolderPath, runFolderName); if (!fs.existsSync(jobFolderPath)) { await fs.mkdir(jobFolderPath); @@ -230,10 +238,12 @@ async function _saveSerialized( settings: Settings, datasetId: string, trackData: MultiTrackRecord, + allowEmpty = false, ) { - const time = moment().format('MM-DD-YYYY_hh-mm-ss'); + const time = moment().format('MM-DD-YYYY_hh-mm-ss.SSS'); const newFileName = `result_${time}.json`; const projectInfo = getProjectDir(settings, datasetId); + const release = await acquireDirLock(projectInfo.basePath); try { const validatedInfo = await getValidatedProjectDir(settings, datasetId); @@ -246,9 +256,11 @@ async function _saveSerialized( ); } catch (err) { // Some part of the project dir didn't exist + if (!allowEmpty) throw err; } const serialized = JSON.stringify(trackData); await fs.writeFile(npath.join(projectInfo.basePath, newFileName), serialized); + await release(); } /** @@ -275,6 +287,7 @@ async function _saveAsJson(absPath: string, data: unknown) { async function saveMetadata(settings: Settings, datasetId: string, args: DatasetMetaMutable) { const projectDirInfo = await getValidatedProjectDir(settings, datasetId); + const release = await acquireDirLock(projectDirInfo.basePath); const existing = await loadJsonMetadata(projectDirInfo.metaFileAbsPath); if (args.confidenceFilters) { existing.confidenceFilters = args.confidenceFilters; @@ -282,7 +295,8 @@ async function saveMetadata(settings: Settings, datasetId: string, args: Dataset if (args.customTypeStyling) { existing.customTypeStyling = args.customTypeStyling; } - _saveAsJson(projectDirInfo.metaFileAbsPath, existing); + await _saveAsJson(projectDirInfo.metaFileAbsPath, existing); + await release(); } /** @@ -317,7 +331,7 @@ async function processOtherAnnotationFiles( const data: MultiTrackRecord = {}; tracks.forEach((t) => { data[t.trackId.toString()] = t; }); // eslint-disable-next-line no-await-in-loop - await _saveSerialized(settings, datasetId, data); + await _saveSerialized(settings, datasetId, data, true); processedFiles.push(path); break; // Exit on first successful detection load } catch (err) { @@ -456,7 +470,7 @@ async function importMedia(settings: Settings, path: string): Promise } /* Finally create an empty file as fallback */ if (!foundDetections) { - await _saveSerialized(settings, dsId, {}); + await _saveSerialized(settings, dsId, {}, true); } return jsonMeta; diff --git a/client/platform/desktop/backend/server.ts b/client/platform/desktop/backend/server.ts index ba1378db9..6f0199eec 100644 --- a/client/platform/desktop/backend/server.ts +++ b/client/platform/desktop/backend/server.ts @@ -42,66 +42,68 @@ function makeMediaUrl(filepath: string): string { return `http://${addr.address}:${addr.port}/api/media?path=${filepath}`; } -function fail(res: express.Response, code: number, message: string) { - return res.status(code).json({ message }).end(); -} - -apirouter.get('/', (_, res) => { - res.send('Electron REST backend.'); -}); - /* LOAD metadata */ -apirouter.get('/dataset/:id/meta', async (req, res) => { +apirouter.get('/dataset/:id/meta', async (req, res, next) => { try { const ds = await common.loadMetadata(settings.get(), req.params.id, makeMediaUrl); res.json(ds); } catch (err) { - fail(res, 500, err); + err.status = 500; + next(err); } }); /* SAVE metadata */ -apirouter.post('/dataset/:id/meta', async (req, res) => { +apirouter.post('/dataset/:id/meta', async (req, res, next) => { try { await common.saveMetadata(settings.get(), req.params.id, req.body); - res.status(200); + res.status(200).send('done'); } catch (err) { - fail(res, 500, err); + err.status = 500; + next(err); } }); /* SAVE detections */ -apirouter.post('/dataset/:id/detections', async (req, res) => { +apirouter.post('/dataset/:id/detections', async (req, res, next) => { try { const args = req.body as SaveDetectionsArgs; await common.saveDetections(settings.get(), req.params.id, args); - return res.status(200); + res.status(200).send('done'); } catch (err) { - console.error(err); - return fail(res, 500, err); - throw err; + err.status = 500; + next(err); } + return null; }); /* IMPORT dataset */ -apirouter.post('/import', async (req, res) => { +apirouter.post('/import', async (req, res, next) => { const { path } = req.query; if (!path || Array.isArray(path)) { - return fail(res, 400, `Invalid path: ${path}`); + return next({ + status: 500, + statusMessage: `Invalid path: ${path}`, + }); } try { const meta = await common.importMedia(settings.get(), path.toString()); - return res.json(meta); + res.json(meta); } catch (err) { - return fail(res, 500, err); + err.status = 500; + next(err); } + return null; }); /* STREAM media */ -apirouter.get('/media', (req, res) => { +apirouter.get('/media', (req, res, next) => { let { path } = req.query; if (!path || Array.isArray(path)) { - return fail(res, 400, `Invalid path: ${path}`); + return next({ + status: 400, + statusMessage: `Invalid path: ${path}`, + }); } path = path.toString(); @@ -109,23 +111,38 @@ apirouter.get('/media', (req, res) => { try { filestat = fs.statSync(path); if (!filestat.isFile()) { - return fail(res, 404, `Invalid file for path: ${path}`); + return next({ + status: 404, + statusMessage: `Invalid file for path: ${path}`, + }); } } catch (err) { - return fail(res, 404, `No such path ${path}`); + return next({ + status: 404, + statusMessage: `No such path ${path}`, + }); } const mimetype = mime.lookup(path); if (mimetype === false) { - return fail(res, 400, `Mime lookup failed for path: ${path}`); + return next({ + status: 400, + statusMessage: `Mime lookup failed for path: ${path}`, + }); } if (!supportedMediaTypes.includes(mimetype)) { - return fail(res, 400, 'Cannot request this mime type'); + return next({ + status: 400, + statusMessage: 'Cannot request this mime type', + }); } const ranges = req.headers.range && rangeParser(filestat.size, req.headers.range); if (ranges === -1 || ranges === -2) { - return fail(res, 400, `Range parse failed: ${req.headers.range}`); + return next({ + status: 400, + statusMessage: `Range parse failed: ${req.headers.range}`, + }); } if (ranges === undefined || ranges === '') { @@ -163,6 +180,20 @@ if (process.env.NODE_ENV === 'development') { app.use(bodyparser.json()); app.use('/api', apirouter); +function fail( + err: { status?: number; statusMessage?: string }, + req: express.Request, + res: express.Response, +) { + res.status(err.status || 500).json({ message: err.statusMessage || err }); +} + +apirouter.get('/', (_, res) => { + res.send('Electron REST backend.'); +}); + +app.use(fail); + /* Singleton listen function will only create a server once */ function listen(callback: (server: http.Server) => void) { if (!server) { diff --git a/client/viame-web-common/components/ConfidenceFilter.vue b/client/viame-web-common/components/ConfidenceFilter.vue index 9c5675f93..7932f3ae7 100644 --- a/client/viame-web-common/components/ConfidenceFilter.vue +++ b/client/viame-web-common/components/ConfidenceFilter.vue @@ -1,5 +1,5 @@ @@ -32,8 +36,8 @@ export default { class="px-3 mb-2" persistent-hint @input="updateConfidence" - @end="$emit('end')" - @mouseup="$emit('end')" + @end="emitEnd" + @mouseup="emitEnd" /> diff --git a/client/yarn.lock b/client/yarn.lock index 566909b9a..3745b9562 100644 --- a/client/yarn.lock +++ b/client/yarn.lock @@ -2318,6 +2318,13 @@ dependencies: "@types/node" "*" +"@types/proper-lockfile@^4.1.1": + version "4.1.1" + resolved "https://registry.yarnpkg.com/@types/proper-lockfile/-/proper-lockfile-4.1.1.tgz#99f026cbfdbe6305bdd454ffd5fefc1bd064beb1" + integrity sha512-HAjVfDa73pFgivViHyDu8HHHcds+W4MgOuZZAdyFJrHS8ngtCXmhl4hc2YXqSOwO6Bsa+iF2Sgxb2+gv874VOQ== + dependencies: + "@types/retry" "*" + "@types/pump@^1.1.0": version "1.1.0" resolved "https://registry.yarnpkg.com/@types/pump/-/pump-1.1.0.tgz#ed5214af511da32b6ee85c8d33ad3d59bb79ad8f" @@ -2355,6 +2362,11 @@ resolved "https://registry.yarnpkg.com/@types/resize-observer-browser/-/resize-observer-browser-0.1.4.tgz#84879a4d6d4aaefde53d4b29c91c0c4cbcffc26f" integrity sha512-rPvqs+1hL/5hbES/0HTdUu4lvNmneiwKwccbWe7HGLWbnsLdqKnQHyWLg4Pj0AMO7PLHCwBM1Cs8orChdkDONg== +"@types/retry@*": + version "0.12.0" + resolved "https://registry.yarnpkg.com/@types/retry/-/retry-0.12.0.tgz#2b35eccfcee7d38cd72ad99232fbd58bffb3c84d" + integrity sha512-wWKOClTTiizcZhXnPY4wikVAwmdYHp8q6DmC+EJUzAMsycb7HB32Kh9RN4+0gExjmPmZSAQjgURXIGATPegAvA== + "@types/serve-static@*": version "1.13.8" resolved "https://registry.yarnpkg.com/@types/serve-static/-/serve-static-1.13.8.tgz#851129d434433c7082148574ffec263d58309c46" @@ -12980,6 +12992,15 @@ prompts@^2.0.1: kleur "^3.0.3" sisteransi "^1.0.4" +proper-lockfile@^4.1.1: + version "4.1.1" + resolved "https://registry.yarnpkg.com/proper-lockfile/-/proper-lockfile-4.1.1.tgz#284cf9db9e30a90e647afad69deb7cb06881262c" + integrity sha512-1w6rxXodisVpn7QYvLk706mzprPTAPCYAqxMvctmPN3ekuRk/kuGkGc82pangZiAt4R3lwSuUzheTTn0/Yb7Zg== + dependencies: + graceful-fs "^4.1.11" + retry "^0.12.0" + signal-exit "^3.0.2" + property-information@^5.0.1: version "5.6.0" resolved "https://registry.yarnpkg.com/property-information/-/property-information-5.6.0.tgz#61675545fb23002f245c6540ec46077d4da3ed69"