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
This commit is contained in:
Par Winzell
2010-10-07 19:21:48 +00:00
parent 3069062034
commit d26267c704
8 changed files with 720 additions and 0 deletions
@@ -0,0 +1,6 @@
<module>
<inherits name="com.threerings.web.Base"/>
<source path="client"/>
<source path="gwt"/>
</module>
@@ -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<ConfigurationResult>() {
public void onSuccess (ConfigurationResult result) {
gotData(result);
}
});
}
public void submitChanges (String key, ConfigField[] modified,
AsyncCallback<ConfigurationRecord> callback)
{
_configsvc.updateConfiguration(key, modified, callback);
}
protected void gotData (ConfigurationResult result)
{
clear();
if (result.records.isEmpty()) {
return;
}
for (Entry<String, ConfigurationRecord> 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<String, ConfigEditorTab> _tabs = Maps.newHashMap();
protected static final ConfigServiceAsync _configsvc = GWT.create(ConfigService.class);
}
@@ -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<ConfigurationRecord> 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<ConfigurationRecord>(_submit) {
protected boolean callService () {
List<ConfigField> 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<ConfigFieldEditor> _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);
}
};
}
@@ -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<Boolean>() {
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;
}
@@ -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<ConfigField>
{
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<ConfigField>
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();
}
}
@@ -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<String, ConfigurationRecord> 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;
}
@@ -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<ConfigurationResult> callback);
/**
* The async version of {@link com.threerings.admin.web.gwt.ConfigService#updateConfiguration}.
*/
public void updateConfiguration (
String key, ConfigField[] updates, AsyncCallback<ConfigurationRecord> callback);
}
@@ -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<ConfigurationResult> waiter =
new ServletWaiter<ConfigurationResult>("getConfiguration");
_omgr.postRunnable(new Runnable() {
public void run () {
Map<String, ConfigurationRecord> 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<ConfigurationRecord> waiter =
new ServletWaiter<ConfigurationRecord>("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<ConfigField> 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<Class<?>, FieldType> TYPES = ImmutableMap.<Class<?>, 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();
}