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();
+}