diff --git a/grails-app/controllers/io/xh/hoist/impl/XhController.groovy b/grails-app/controllers/io/xh/hoist/impl/XhController.groovy index 14446044..06acbdb0 100644 --- a/grails-app/controllers/io/xh/hoist/impl/XhController.groovy +++ b/grails-app/controllers/io/xh/hoist/impl/XhController.groovy @@ -176,6 +176,11 @@ class XhController extends BaseController { renderJSON(ret.formatForClient()) } + def findJsonBlob(String type, String name, String owner) { + def ret = jsonBlobService.find(type, name, owner) + renderJSON(ret?.formatForClient()) + } + def listJsonBlobs(String type, boolean includeValue) { def ret = jsonBlobService.list(type) renderJSON(ret*.formatForClient(includeValue)) @@ -191,6 +196,11 @@ class XhController extends BaseController { renderJSON(ret.formatForClient()) } + def createOrUpdateJsonBlob(String type, String name, String update) { + def ret = jsonBlobService.createOrUpdate(type, name, parseObject(update)) + renderJSON(ret.formatForClient()) + } + def archiveJsonBlob(String token) { def ret = jsonBlobService.archive(token) renderJSON(ret.formatForClient()) diff --git a/grails-app/controllers/io/xh/hoist/impl/XhViewController.groovy b/grails-app/controllers/io/xh/hoist/impl/XhViewController.groovy new file mode 100644 index 00000000..f803e283 --- /dev/null +++ b/grails-app/controllers/io/xh/hoist/impl/XhViewController.groovy @@ -0,0 +1,60 @@ +/* + * This file belongs to Hoist, an application development toolkit + * developed by Extremely Heavy Industries (www.xh.io | info@xh.io) + * + * Copyright © 2023 Extremely Heavy Industries Inc. + */ + +package io.xh.hoist.impl + +import groovy.transform.CompileStatic +import io.xh.hoist.BaseController +import io.xh.hoist.security.AccessAll +import io.xh.hoist.view.ViewService + +@AccessAll +@CompileStatic +class XhViewController extends BaseController { + + ViewService viewService + + //---------------------------- + // ViewManager state + support + //----------------------------- + def allData(String type, String viewInstance) { + renderJSON(viewService.getAllData(type, viewInstance)) + } + + def updateState(String type, String viewInstance) { + viewService.updateState(type, viewInstance, parseRequestJSON()) + renderJSON([success: true]) + } + + //--------------------------- + // Individual View management + //---------------------------- + def get(String token) { + renderJSON(viewService.get(token)) + } + + def create() { + renderJSON(viewService.create(parseRequestJSON())) + } + + def delete(String tokens) { + viewService.delete(tokens.split(',').toList()) + renderJSON([success: true]) + } + + def updateInfo(String token) { + renderJSON(viewService.updateInfo(token, parseRequestJSON())) + } + + def updateValue(String token) { + renderJSON(viewService.updateValue(token, parseRequestJSON())) + } + + def makeGlobal(String token) { + renderJSON(viewService.makeGlobal(token)) + } +} diff --git a/grails-app/services/io/xh/hoist/admin/MemoryMonitoringService.groovy b/grails-app/services/io/xh/hoist/admin/MemoryMonitoringService.groovy index 6ba54e9d..e2f63b08 100644 --- a/grails-app/services/io/xh/hoist/admin/MemoryMonitoringService.groovy +++ b/grails-app/services/io/xh/hoist/admin/MemoryMonitoringService.groovy @@ -40,7 +40,6 @@ class MemoryMonitoringService extends BaseService { private Date _lastInfoLogged private final String blobOwner = 'xhMemoryMonitoringService' private final static String blobType = isProduction ? 'xhMemorySnapshots' : "xhMemorySnapshots_$appEnvironment" - private String blobToken void init() { createTimer( @@ -211,19 +210,14 @@ class MemoryMonitoringService extends BaseService { private void persistSnapshots() { try { - if (blobToken) { - jsonBlobService.update(blobToken, [value: snapshots], blobOwner) - } else { - def blob = jsonBlobService.create([ - name : clusterService.instanceName, - type : blobType, - value: snapshots - ], blobOwner) - blobToken = blob.token - } + jsonBlobService.createOrUpdate( + blobType, + clusterService.instanceName, + [value: snapshots], + blobOwner + ) } catch (Exception e) { logError('Failed to persist memory snapshots', e) - blobToken = null } } diff --git a/grails-app/services/io/xh/hoist/jsonblob/JsonBlobService.groovy b/grails-app/services/io/xh/hoist/jsonblob/JsonBlobService.groovy index 90718309..b46d335c 100644 --- a/grails-app/services/io/xh/hoist/jsonblob/JsonBlobService.groovy +++ b/grails-app/services/io/xh/hoist/jsonblob/JsonBlobService.groovy @@ -20,12 +20,15 @@ class JsonBlobService extends BaseService implements DataBinder { @ReadOnly JsonBlob get(String token, String username = username) { JsonBlob ret = JsonBlob.findByTokenAndArchivedDate(token, 0) - if (!ret) { - throw new RuntimeException("Active JsonBlob not found with token '$token'") - } - if (!passesAcl(ret, username)) { - throw new NotAuthorizedException("User '$username' does not have access to JsonBlob with token '$token'") - } + if (!ret) throw new RuntimeException("Active JsonBlob not found with token '$token'") + ensureAccess(ret, username) + return ret + } + + @ReadOnly + JsonBlob find(String type, String name, String owner, String username = username) { + def ret = JsonBlob.findByTypeAndNameAndOwnerAndArchivedDate(type, name, owner, 0) + if (ret) ensureAccess(ret, username) return ret } @@ -36,6 +39,12 @@ class JsonBlobService extends BaseService implements DataBinder { .findAll { passesAcl(it, username) } } + @Transactional + JsonBlob update(String token, Map data, String username = username) { + def blob = get(token, username) + return updateInternal(blob, data, username) + } + @Transactional JsonBlob create(Map data, String username = username) { data = [*: data, owner: username, lastUpdatedBy: username] @@ -46,17 +55,11 @@ class JsonBlobService extends BaseService implements DataBinder { } @Transactional - JsonBlob update(String token, Map data, String username = username) { - def blob = get(token, username) - if (data) { - data = [*: data, lastUpdatedBy: username] - if (data.containsKey('value')) data.value = serialize(data.value) - if (data.containsKey('meta')) data.meta = serialize(data.meta) - - bindData(blob, data) - blob.save() - } - return blob + JsonBlob createOrUpdate(String type, String name, Map data, String username = username) { + def blob = find(type, name, username, username) + return blob ? + updateInternal(blob, data, username) : + create([*: data, type: type, name: name, owner: username], username) } @Transactional @@ -71,7 +74,25 @@ class JsonBlobService extends BaseService implements DataBinder { //------------------------- // Implementation //------------------------- + private JsonBlob updateInternal(JsonBlob blob, Map data, String username) { + if (data) { + data = [*: data, lastUpdatedBy: username] + if (data.containsKey('value')) data.value = serialize(data.value) + if (data.containsKey('meta')) data.meta = serialize(data.meta) + + bindData(blob, data) + blob.save() + } + return blob + } + private boolean passesAcl(JsonBlob blob, String username) { return blob.acl == '*' || blob.owner == username } + + private ensureAccess(JsonBlob blob, String username) { + if (!passesAcl(blob, username)) { + throw new NotAuthorizedException("User '$username' does not have access to JsonBlob with token '${blob.token}'") + } + } } diff --git a/grails-app/services/io/xh/hoist/view/ViewService.groovy b/grails-app/services/io/xh/hoist/view/ViewService.groovy new file mode 100644 index 00000000..e4cdc387 --- /dev/null +++ b/grails-app/services/io/xh/hoist/view/ViewService.groovy @@ -0,0 +1,169 @@ +/* + * This file belongs to Hoist, an application development toolkit + * developed by Extremely Heavy Industries (www.xh.io | info@xh.io) + * + * Copyright © 2023 Extremely Heavy Industries Inc. + */ + +package io.xh.hoist.view + +import groovy.transform.CompileStatic +import io.xh.hoist.BaseService +import io.xh.hoist.jsonblob.JsonBlob +import io.xh.hoist.jsonblob.JsonBlobService +import io.xh.hoist.track.TrackService + +import static io.xh.hoist.json.JSONParser.parseObject + +/** + * Manage all View state for Hoist's built-in client-side views + */ +@CompileStatic +class ViewService extends BaseService { + + JsonBlobService jsonBlobService + TrackService trackService + + static final String STATE_BLOB_NAME = 'xhUserState'; + + //---------------------------- + // ViewManager state + support + //----------------------------- + /** + * Get all the views, and the current view state for a user + */ + Map getAllData(String type, String viewInstance) { + def blobs = jsonBlobService.list(type).split { it.name == STATE_BLOB_NAME } + def (rawState, views) = [blobs[0], blobs[1]] + + // Transform state + rawState = rawState ? parseObject(rawState[0].value) : null + def state = [ + userPinned: rawState?.userPinned ?: [:], + autoSave: rawState?.autoSave ?: false + ] + Map currentView = rawState?.currentView as Map + if (currentView?.containsKey(viewInstance)) { + state.currentView = currentView[viewInstance] + } + + // Transform views + views = views*.formatForClient(false) + + return [state: state, views: views] + } + + /** Update state for this user */ + void updateState(String type, String viewInstance, Map update) { + def currBlob = jsonBlobService.find(type, STATE_BLOB_NAME, username), + currValue = parseObject(currBlob.value), + newValue = [ + currentView: currValue?.currentView ?: [:], + userPinned : currValue?.userPinned ?: [:], + autoSave : currValue?.autoSave ?: false + ] + + if (update.containsKey('currentView')) newValue.currentView[viewInstance] = update.currentView + if (update.containsKey('userPinned')) newValue.userPinned = update.userPinned + if (update.containsKey('autoSave')) newValue.autoSave = update.autoSave + + jsonBlobService.createOrUpdate(type, STATE_BLOB_NAME, [value: newValue]) + } + + //--------------------------- + // Individual View management + //---------------------------- + /** Fetch the latest version of a view. */ + Map get(String token) { + jsonBlobService.get(token).formatForClient(true) + } + + /** Fetch the latest version of a view. */ + Map create(Map data) { + def ret = jsonBlobService.create( + type: data.type, + name: data.name, + description: data.description, + acl: data.isShared ? '*' : null, + meta: [group: data.group, isShared: data.isShared], + value: data.value + ) + trackChange('Created View', ret) + ret.formatForClient(true) + } + + /** Update a views metadata */ + Map updateInfo(String token, Map data) { + def existing = jsonBlobService.get(token), + existingMeta = parseObject(existing.meta), + isGlobal = existingMeta.isGlobal, + isShared = data.containsKey('isShared') ? data.isShared : existingMeta.isShared; + + def ret = jsonBlobService.update( + token, + [ + *: data, + acl: isGlobal || isShared ? '*' : null, + meta: isGlobal ? + [group: data.group, isDefaultPinned: !!data.isDefaultPinned]: + [group: data.group, isShared: !!data.isShared], + ] + ) + trackChange('Updated View Info', ret) + ret.formatForClient(true) + } + + /** Update a views value */ + Map updateValue(String token, Map value) { + def ret = jsonBlobService.update(token, [value: value]); + if (ret.owner == null) { + trackChange('Updated Global View definition', ret); + } + ret.formatForClient(true) + } + + /** Make a view global */ + Map makeGlobal(String token) { + def existing = jsonBlobService.get(token), + meta = parseObject(existing.meta)?.findAll { it.key != 'isShared' }, + ret = jsonBlobService.update(token, [owner: null, acl: '*', meta: meta]) + + this.trackChange('Made View Global', ret) + ret.formatForClient(true) + } + + + /** Bulk Delete views */ + void delete(List tokens) { + List failures = [] + tokens.each { + try { + jsonBlobService.archive(it) + } catch (Exception e) { + failures << e + logError('Failed to delete View', [token: it], e) + } + } + def successCount = tokens.size() - failures.size() + if (successCount) { + trackChange('Deleted Views', [count: successCount]) + } + + if (failures) { + throw new RuntimeException("Failed to delete ${failures.size()} view(s)", failures.first()) + } + } + + //-------------------- + // Implementation + //--------------------- + private trackChange(String msg, Object data) { + trackService.track( + msg: msg, + category: 'Views', + data: data instanceof JsonBlob ? + [name: data.name, token: data.token, isGlobal: data.owner == null] : + data + ) + } +}