From d26267c70469d5b71b12434605570008d3086eff Mon Sep 17 00:00:00 2001 From: Par Winzell Date: Thu, 7 Oct 2010 19:21:48 +0000 Subject: [PATCH] GWT frontend and servlet for editing runtime configuration. git-svn-id: svn+ssh://src.earth.threerings.net/narya/trunk@6178 542714f4-19e9-0310-aa3c-eee0fc999fb1 --- .../com/threerings/admin/web/Admin.gwt.xml | 6 + .../admin/web/client/ConfigEditorPanel.java | 72 +++++++ .../admin/web/client/ConfigEditorTab.java | 107 ++++++++++ .../admin/web/client/ConfigFieldEditor.java | 196 ++++++++++++++++++ .../threerings/admin/web/gwt/ConfigField.java | 100 +++++++++ .../admin/web/gwt/ConfigService.java | 51 +++++ .../admin/web/gwt/ConfigServiceAsync.java | 26 +++ .../admin/web/server/ConfigServlet.java | 162 +++++++++++++++ 8 files changed, 720 insertions(+) create mode 100644 src/java/com/threerings/admin/web/Admin.gwt.xml create mode 100644 src/java/com/threerings/admin/web/client/ConfigEditorPanel.java create mode 100644 src/java/com/threerings/admin/web/client/ConfigEditorTab.java create mode 100644 src/java/com/threerings/admin/web/client/ConfigFieldEditor.java create mode 100644 src/java/com/threerings/admin/web/gwt/ConfigField.java create mode 100644 src/java/com/threerings/admin/web/gwt/ConfigService.java create mode 100644 src/java/com/threerings/admin/web/gwt/ConfigServiceAsync.java create mode 100644 src/java/com/threerings/admin/web/server/ConfigServlet.java diff --git a/src/java/com/threerings/admin/web/Admin.gwt.xml b/src/java/com/threerings/admin/web/Admin.gwt.xml new file mode 100644 index 000000000..c47c274be --- /dev/null +++ b/src/java/com/threerings/admin/web/Admin.gwt.xml @@ -0,0 +1,6 @@ + + + + + + diff --git a/src/java/com/threerings/admin/web/client/ConfigEditorPanel.java b/src/java/com/threerings/admin/web/client/ConfigEditorPanel.java new file mode 100644 index 000000000..150fe3808 --- /dev/null +++ b/src/java/com/threerings/admin/web/client/ConfigEditorPanel.java @@ -0,0 +1,72 @@ +// +// $Id: $ + +package com.threerings.admin.web.client; + +import java.util.Map; +import java.util.Map.Entry; + +import com.google.common.collect.Maps; + +import com.google.gwt.core.client.GWT; +import com.google.gwt.user.client.rpc.AsyncCallback; +import com.google.gwt.user.client.rpc.ServiceDefTarget; +import com.google.gwt.user.client.ui.TabPanel; + +import com.threerings.admin.web.client.ConfigEditorTab.ConfigAccessor; +import com.threerings.admin.web.gwt.ConfigField; +import com.threerings.admin.web.gwt.ConfigService; +import com.threerings.admin.web.gwt.ConfigService.ConfigurationRecord; +import com.threerings.admin.web.gwt.ConfigService.ConfigurationResult; +import com.threerings.admin.web.gwt.ConfigServiceAsync; +import com.threerings.gwt.util.PopupCallback; + + +/** + * The main panel of the configuration editor. All service calls are routed through here. + * Subclass this class in your project. + */ +public abstract class ConfigEditorPanel extends TabPanel + implements ConfigAccessor +{ + public ConfigEditorPanel () + { + addStyleName("configEditorPanel"); + ((ServiceDefTarget)_configsvc).setServiceEntryPoint(getServiceEntryPoint()); + _configsvc.getConfiguration(new PopupCallback() { + public void onSuccess (ConfigurationResult result) { + gotData(result); + } + }); + } + + public void submitChanges (String key, ConfigField[] modified, + AsyncCallback callback) + { + _configsvc.updateConfiguration(key, modified, callback); + } + + protected void gotData (ConfigurationResult result) + { + clear(); + + if (result.records.isEmpty()) { + return; + } + + for (Entry tab : result.records.entrySet()) { + String tabKey = tab.getKey(); + ConfigEditorTab widget = new ConfigEditorTab(this, tabKey, tab.getValue()); + _tabs.put(tabKey, widget); + add(widget, tabKey); + } + selectTab(0); + } + + /** Should return the absolute path of the servlet that implements {@link ConfigService}. */ + protected abstract String getServiceEntryPoint(); + + protected Map _tabs = Maps.newHashMap(); + + protected static final ConfigServiceAsync _configsvc = GWT.create(ConfigService.class); +} diff --git a/src/java/com/threerings/admin/web/client/ConfigEditorTab.java b/src/java/com/threerings/admin/web/client/ConfigEditorTab.java new file mode 100644 index 000000000..618ba6432 --- /dev/null +++ b/src/java/com/threerings/admin/web/client/ConfigEditorTab.java @@ -0,0 +1,107 @@ +// +// $Id: $ + + +package com.threerings.admin.web.client; + +import java.util.List; + +import com.google.common.collect.Lists; + +import com.google.gwt.user.client.Command; +import com.google.gwt.user.client.rpc.AsyncCallback; +import com.google.gwt.user.client.ui.Button; + +import com.threerings.admin.web.gwt.ConfigField; +import com.threerings.gwt.ui.InfoPopup; +import com.threerings.gwt.ui.SmartTable; + +import com.threerings.admin.web.gwt.ConfigService.ConfigurationRecord; + +import com.threerings.gwt.util.ClickCallback; + +/** + * + */ +public class ConfigEditorTab extends SmartTable +{ + public interface ConfigAccessor + { + void submitChanges (String key, ConfigField[] modified, + AsyncCallback callback); + } + + public ConfigEditorTab (ConfigAccessor parent, String key, ConfigurationRecord record) + { + super("configEditorTab", 5, 5); + + _parent = parent; + _key = key; + + _submit = new Button("Submit Changes"); + + // wire up saving the code on click + new ClickCallback(_submit) { + protected boolean callService () { + List modified = Lists.newArrayList(); + for (ConfigFieldEditor editor : _editors) { + ConfigField field = editor.getModifiedField(); + if (field != null) { + modified.add(field); + } + } + _parent.submitChanges( + _key, modified.toArray(new ConfigField[modified.size()]), this); + return true; + } + protected boolean gotResult (ConfigurationRecord result) { + new InfoPopup("Updated " + result.updates + " fields.").show(); + updateTable(result); + return false; + } + }; + + cell(1, 1).alignRight().widget(_submit); + + updateTable(record); + } + + protected void updateTable (ConfigurationRecord record) + { + SmartTable table = new SmartTable(5, 5); + table.setStyleName("configEditorTable"); + + int row = 0; + for (ConfigField field : record.fields) { + ConfigFieldEditor editor = ConfigFieldEditor.getEditorFor(field, UPDATE_BUTTON); + _editors.add(editor); + table.cell(row, 0).alignRight().widget(editor.getNameWidget()); + table.cell(row, 1).alignLeft().widget(editor.getValueWidget()); + table.cell(row, 2).alignLeft().widget(editor.getResetWidget()); + row ++; + } + + cell(0, 0).colSpan(2).widget(table); + + UPDATE_BUTTON.execute(); + } + + protected List _editors = Lists.newArrayList(); + + protected ConfigAccessor _parent; + protected String _key; + protected Button _submit; + + protected Command UPDATE_BUTTON = new Command () { + public void execute () { + // search for any modified field; if found, enable submissions & exit + for (ConfigFieldEditor editor : _editors) { + if (editor.getModifiedField() != null) { + _submit.setEnabled(true); + return; + } + } + _submit.setEnabled(false); + } + }; +} diff --git a/src/java/com/threerings/admin/web/client/ConfigFieldEditor.java b/src/java/com/threerings/admin/web/client/ConfigFieldEditor.java new file mode 100644 index 000000000..79a3f77fd --- /dev/null +++ b/src/java/com/threerings/admin/web/client/ConfigFieldEditor.java @@ -0,0 +1,196 @@ +// +// $Id: $ + +package com.threerings.admin.web.client; + +import com.google.gwt.user.client.ui.Label; +import com.threerings.admin.web.gwt.ConfigField; +import com.threerings.admin.web.gwt.ConfigField.FieldType; + +import com.google.gwt.dom.client.Style; + +import com.google.gwt.event.dom.client.ChangeEvent; +import com.google.gwt.event.dom.client.ChangeHandler; +import com.google.gwt.event.dom.client.ClickEvent; +import com.google.gwt.event.dom.client.ClickHandler; + +import com.google.gwt.event.logical.shared.ValueChangeEvent; +import com.google.gwt.event.logical.shared.ValueChangeHandler; + +import com.google.gwt.user.client.Command; +import com.google.gwt.user.client.ui.CheckBox; +import com.google.gwt.user.client.ui.TextBox; +import com.google.gwt.user.client.ui.Widget; + +/** + * A class responsible for constructing and exporting the widgets involved in manipulating + * one specific configuration field. + */ +public abstract class ConfigFieldEditor +{ + public static ConfigFieldEditor getEditorFor (ConfigField field, Command onChange) + { + if (field.type == FieldType.BOOLEAN) { + return new CheckboxFieldEditor(field, onChange); + } + return new StringFieldEditor(field, onChange); + } + + /** + * This editor represents values as strings. + */ + protected static class StringFieldEditor extends ConfigFieldEditor + { + public StringFieldEditor (ConfigField field, Command onChange) + { + super(field, onChange); + } + + @Override + protected Widget buildWidget (ConfigField field) + { + _box = new TextBox(); + _box.setStyleName("configStringEditor"); + _box.setVisibleLength(40); + resetField(); + + _box.addChangeHandler(new ChangeHandler() { + public void onChange (ChangeEvent changeEvent) { + // if the string fails conversion, just reset to the old value + if (_field.type.toValue(_box.getText().trim()) == null) { + _box.setText(_field.valStr); + } + updateModificationState(); + } + }); + return _box; + } + + @Override + public ConfigField getModifiedField () + { + Object newValue = _field.type.toValue(_box.getText().trim()); + if (newValue == null) { + return null; + } + String newValStr = newValue.toString(); + if ((newValStr.isEmpty() && _field.valStr == null) ||newValStr.equals(_field.valStr)) { + return null; + } + return new ConfigField(_field.name, _field.type, newValStr); + } + + @Override + protected void resetField () + { + _box.setText(_field.valStr); + } + + protected TextBox _box; + } + + /** + * This editor represents boolean values as checkboxes. + */ + protected static class CheckboxFieldEditor extends ConfigFieldEditor + { + public CheckboxFieldEditor (ConfigField field, Command onChange) + { + super(field, onChange); + } + + @Override + protected Widget buildWidget (ConfigField field) + { + _box = new CheckBox(); + _box.setStyleName("configCheckBoxEditor"); + resetField(); + + _box.addValueChangeHandler(new ValueChangeHandler() { + public void onValueChange (ValueChangeEvent changeEvent) { + updateModificationState(); + } + }); + return _box; + } + + @Override + public ConfigField getModifiedField () + { + String newValStr = Boolean.toString(_box.getValue()); + if (newValStr.equals(_field.valStr)) { + return null; + } + return new ConfigField(_field.name, _field.type, newValStr); + } + + @Override + protected void resetField () + { + _box.setValue(Boolean.valueOf(_field.valStr)); + } + + protected CheckBox _box; + } + + public ConfigFieldEditor (ConfigField field, Command onChange) + { + _field = field; + _onChange = onChange; + + _value = buildWidget(field); + + _name = new Label(field.name); + _name.setStyleName("fieldName"); + + _reset = new Label("X"); + _reset.setStyleName("resetButton"); + _reset.addClickHandler(new ClickHandler() { + public void onClick (ClickEvent event) { + resetField(); + updateModificationState(); + } + }); + _reset.setVisible(false); + } + + protected void updateModificationState () + { + Style style = _value.getElement().getStyle(); + if (getModifiedField() != null) { + style.setBackgroundColor("red"); + _reset.setVisible(true); + + } else { + style.clearBackgroundColor(); + _reset.setVisible(false); + } + _onChange.execute(); + } + + public Widget getNameWidget () + { + return _name; + } + + public Widget getValueWidget () + { + return _value; + } + + public Widget getResetWidget () + { + return _reset; + } + + public abstract ConfigField getModifiedField (); + + protected abstract Widget buildWidget (ConfigField field); + protected abstract void resetField (); + + protected ConfigField _field; + protected Command _onChange; + + protected Label _name, _reset; + protected Widget _value; +} diff --git a/src/java/com/threerings/admin/web/gwt/ConfigField.java b/src/java/com/threerings/admin/web/gwt/ConfigField.java new file mode 100644 index 000000000..e560215f5 --- /dev/null +++ b/src/java/com/threerings/admin/web/gwt/ConfigField.java @@ -0,0 +1,100 @@ +// +// $Id: $ + +package com.threerings.admin.web.gwt; + +import com.google.common.collect.ComparisonChain; + +import com.google.gwt.user.client.rpc.IsSerializable; + +/** + * A GWT-friendly representation of a configuration tuple, consisting of the name of the entry, + * a type enum, and the toString() of the value. + */ +public class ConfigField + implements IsSerializable, Comparable +{ + public enum FieldType + implements IsSerializable + { + INTEGER, + SHORT, + BYTE, + LONG, + FLOAT, + BOOLEAN, + DOUBLE, + STRING; + + /** + * Create a string representation of the given value, which should be of the type + * reflected in this enum. + */ + public String toString (Object value) + { + return (value != null) ? value.toString() : null; + } + + /** + * Convert the given string, which should have been created by {@link #toString(Object)}, + * back into its raw value form. + */ + public Object toValue (String text) + { + switch(this) { + case INTEGER: + return new Integer(text); + case SHORT: + return new Short(text); + case BYTE: + return new Byte(text); + case LONG: + return new Long(text); + case FLOAT: + return new Float(text); + case DOUBLE: + return new Double(text); + case BOOLEAN: + return new Boolean(text); + case STRING: + return text; + } + return null; + } + } + + public String name; + public FieldType type; + public String valStr; + + /** Deserialization constructor. */ + public ConfigField () + { + } + + /** Construct a new ConfigField with the given values. */ + public ConfigField (String name, FieldType type, String valStr) + { + this.name = name; + this.type = type; + this.valStr = valStr; + } + + // from Comparable + public int compareTo (ConfigField o) + { + return ComparisonChain.start().compare(name, o.name).result(); + } + + @Override // from Object + public boolean equals (Object o) + { + return compareTo((ConfigField) o) == 0; + } + + @Override // from Object + public int hashCode () + { + return name.hashCode(); + } +} diff --git a/src/java/com/threerings/admin/web/gwt/ConfigService.java b/src/java/com/threerings/admin/web/gwt/ConfigService.java new file mode 100644 index 000000000..bb181edd0 --- /dev/null +++ b/src/java/com/threerings/admin/web/gwt/ConfigService.java @@ -0,0 +1,51 @@ +// +// $Id: $ + +package com.threerings.admin.web.gwt; + +import java.util.Map; + +import com.google.gwt.user.client.rpc.IsSerializable; +import com.google.gwt.user.client.rpc.RemoteService; + +import com.threerings.web.gwt.ServiceException; + +/** + * Defines remote services available to admins. + */ +public interface ConfigService extends RemoteService +{ + /** + * The current runtime configuration of a server, a collection of {@link ConfigurationRecord} + * objects indexed by key. + */ + public static class ConfigurationResult + implements IsSerializable + { + public Map records; + } + + /** + * The runtime configuration of a single {@link com.threerings.admin.data.ConfigObject}. + */ + public static class ConfigurationRecord + implements IsSerializable + { + public ConfigField[] fields; + public int updates; + } + + /** + * Retrieve all the runtime configuration held by the server and return it in a format + * that is digestible by GWT. + */ + public ConfigurationResult getConfiguration () throws ServiceException; + + /** + * Submit a collection of updated fields to the server for application to its runtime + * configuration. A new snapshot of the configuration state is returned for sanity checking + * purposes. + */ + public ConfigurationRecord updateConfiguration (String key, ConfigField[] updates) + throws ServiceException; +} diff --git a/src/java/com/threerings/admin/web/gwt/ConfigServiceAsync.java b/src/java/com/threerings/admin/web/gwt/ConfigServiceAsync.java new file mode 100644 index 000000000..e1207cc90 --- /dev/null +++ b/src/java/com/threerings/admin/web/gwt/ConfigServiceAsync.java @@ -0,0 +1,26 @@ +// +// $Id: $ + +package com.threerings.admin.web.gwt; + +import com.google.gwt.user.client.rpc.AsyncCallback; + +import com.threerings.admin.web.gwt.ConfigService.ConfigurationRecord; +import com.threerings.admin.web.gwt.ConfigService.ConfigurationResult; + +/** + * Provides the asynchronous version of {@link com.threerings.admin.web.gwt.AdminService}. + */ +public interface ConfigServiceAsync +{ + /** + * The async version of {@link com.threerings.admin.web.gwt.ConfigService#getConfig}. + */ + public void getConfiguration (AsyncCallback callback); + + /** + * The async version of {@link com.threerings.admin.web.gwt.ConfigService#updateConfiguration}. + */ + public void updateConfiguration ( + String key, ConfigField[] updates, AsyncCallback callback); +} diff --git a/src/java/com/threerings/admin/web/server/ConfigServlet.java b/src/java/com/threerings/admin/web/server/ConfigServlet.java new file mode 100644 index 000000000..9f2f02473 --- /dev/null +++ b/src/java/com/threerings/admin/web/server/ConfigServlet.java @@ -0,0 +1,162 @@ +// +// $Id: $ + +package com.threerings.admin.web.server; + +import java.lang.reflect.Field; +import java.lang.reflect.Modifier; +import java.util.List; +import java.util.Map; + +import com.google.inject.Inject; + +import com.google.gwt.user.server.rpc.RemoteServiceServlet; + +import com.google.common.collect.ImmutableMap; +import com.google.common.collect.Iterables; +import com.google.common.collect.Lists; +import com.google.common.collect.Maps; + +import com.threerings.presents.data.InvocationCodes; +import com.threerings.presents.dobj.DObject; +import com.threerings.presents.dobj.ObjectAccessException; +import com.threerings.presents.dobj.RootDObjectManager; + +import com.threerings.web.gwt.ServiceException; +import com.threerings.web.server.ServletWaiter; + +import com.threerings.admin.server.ConfigRegistry; +import com.threerings.admin.web.gwt.ConfigField; +import com.threerings.admin.web.gwt.ConfigField.FieldType; +import com.threerings.admin.web.gwt.ConfigService; + +import static com.threerings.admin.Log.log; + +/** + * Provides the server implementation of {@link ConfigService}. + */ +public abstract class ConfigServlet extends RemoteServiceServlet + implements ConfigService +{ + // from interface ConfigService + public ConfigurationResult getConfiguration () + throws ServiceException + { + requireAdminUser(); + + final ServletWaiter waiter = + new ServletWaiter("getConfiguration"); + + _omgr.postRunnable(new Runnable() { + public void run () { + Map tabs = Maps.newHashMap(); + for (String key : _confReg.getKeys()) { + ConfigurationRecord record = buildRecord(key); + if (record == null) { + waiter.requestFailed( + new ServiceException(InvocationCodes.E_INTERNAL_ERROR)); + return; + } + tabs.put(key, record); + } + + ConfigurationResult result = new ConfigurationResult(); + result.records = tabs; + waiter.requestCompleted(result); + } + }); + + return waiter.waitForResult(); + } + + // from interface ConfigService + public ConfigurationRecord updateConfiguration (final String key, final ConfigField[] updates) + throws ServiceException + { + requireAdminUser(); + + final ServletWaiter waiter = + new ServletWaiter("updateConfiguration"); + + _omgr.postRunnable(new Runnable() { + public void run () { + DObject object = _confReg.getObject(key); + object.startTransaction(); + + int updateCount = 0; + for (ConfigField update : updates) { + try { + object.changeAttribute(update.name, update.type.toValue(update.valStr)); + updateCount ++; + } catch (ObjectAccessException oae) { + log.warning("Failed to update field", "field", update.name, oae); + } + } + object.commitTransaction(); + + ConfigurationRecord record = buildRecord(key); + record.updates = updateCount; + waiter.requestCompleted(record); + } + }); + + return waiter.waitForResult(); + } + + protected ConfigurationRecord buildRecord (String key) + { + DObject object = _confReg.getObject(key); + List configFields = Lists.newArrayList(); + Field[] fields = object.getClass().getFields(); + for (Field field : fields) { + if (field.getModifiers() != Modifier.PUBLIC) { + continue; + } + FieldType type = TYPES.get(field.getType()); + if (type == null) { + log.warning("Unknown field type", "field", field.getName(), + "type", field.getType()); + return null; + } + try { + Object value = field.get(object); + String valStr = type.toString(value); + configFields.add(new ConfigField(field.getName(), type, valStr)); + + } catch (IllegalAccessException e) { + log.warning("Failure reflecting on configuration object", "key", key, + "object", object, "field", field, e); + return null; + } + } + ConfigurationRecord record = new ConfigurationRecord(); + record.fields = Iterables.toArray(configFields, ConfigField.class); + return record; + } + + /** + * Implemented on a project by project basis to provide a security fence for configuration + * editing powers. + */ + protected abstract void requireAdminUser () + throws ServiceException; + + @Inject protected ConfigRegistry _confReg; + @Inject protected RootDObjectManager _omgr; + + protected static Map, FieldType> TYPES = ImmutableMap., FieldType>builder() + .put(Integer.class, FieldType.INTEGER) + .put(Integer.TYPE, FieldType.INTEGER) + .put(Short.class, FieldType.SHORT) + .put(Short.TYPE, FieldType.SHORT) + .put(Long.class, FieldType.LONG) + .put(Long.TYPE, FieldType.LONG) + .put(Float.class, FieldType.FLOAT) + .put(Float.TYPE, FieldType.FLOAT) + .put(Double.class, FieldType.DOUBLE) + .put(Double.TYPE, FieldType.DOUBLE) + .put(Boolean.class, FieldType.BOOLEAN) + .put(Boolean.TYPE, FieldType.BOOLEAN) + .put(String.class, FieldType.STRING) + .build(); +}