FELIX-1261: Install/uninstall features from web console

git-svn-id: https://svn.apache.org/repos/asf/felix/trunk@790920 13f79535-47bb-0310-9956-ffa450edef68
diff --git a/karaf/webconsole/pom.xml b/karaf/webconsole/pom.xml
index 432c9ef..857fadf 100644
--- a/karaf/webconsole/pom.xml
+++ b/karaf/webconsole/pom.xml
@@ -32,7 +32,7 @@
   <artifactId>org.apache.felix.karaf.webconsole</artifactId>
   <packaging>bundle</packaging>
   <version>1.2.0-SNAPSHOT</version>
-  <name>Apache Felix Karaf :: Web Console</name>
+  <name>Apache Felix Karaf :: Web Console Features Plugin</name>
   
   <dependencies>
     <dependency>
@@ -56,6 +56,11 @@
       <scope>provided</scope>
     </dependency>
     <dependency>
+      <groupId>commons-logging</groupId>
+      <artifactId>commons-logging</artifactId>
+      <scope>provided</scope>
+    </dependency>
+    <dependency>
       <groupId>org.apache.felix.karaf.gshell</groupId>
       <artifactId>org.apache.felix.karaf.gshell.features</artifactId>
     </dependency>
@@ -64,6 +69,13 @@
       <artifactId>org.apache.servicemix.bundles.junit</artifactId>
       <scope>test</scope>
     </dependency>
+    <dependency>
+      <groupId>org.json</groupId>
+      <artifactId>json</artifactId>
+      <version>20070829</version>
+      <scope>compile</scope>
+      <optional>true</optional>
+    </dependency>
   </dependencies>
   
   <build>
@@ -73,7 +85,12 @@
         <artifactId>maven-bundle-plugin</artifactId>
         <configuration>
           <instructions>
-            <Private-Package>org.apache.felix.karaf.webconsole*</Private-Package>
+            <Export-Package>org.apache.felix.karaf.webconsole;version=${pom.version}</Export-Package>
+            <Embed-Dependency>
+               <!-- Required for JSON data transfer -->
+               <!-- TODO: this needs to be put in a common place for reuse. -->
+               json
+            </Embed-Dependency>
           </instructions>
         </configuration>
       </plugin>
diff --git a/karaf/webconsole/src/main/java/org/apache/felix/karaf/webconsole/Feature.java b/karaf/webconsole/src/main/java/org/apache/felix/karaf/webconsole/Feature.java
new file mode 100644
index 0000000..cc2a627
--- /dev/null
+++ b/karaf/webconsole/src/main/java/org/apache/felix/karaf/webconsole/Feature.java
@@ -0,0 +1,47 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.felix.karaf.webconsole;
+
+/**
+ * Represents a feature with a name, version and state 
+ */
+public class Feature {
+
+  public enum State {
+    INSTALLED, UNINSTALLED, UNKNOWN;
+
+    @Override
+    public String toString() {
+      //only capitalize the first letter
+      String s = super.toString();
+      return s.substring( 0, 1 ) + s.substring( 1 ).toLowerCase();
+    }
+  };
+
+  protected String name;
+
+  protected String version;
+
+  protected State state;
+
+
+  public Feature(String name, String version, State state) {
+    this.name = name;
+    this.version = version;
+    this.state = state;
+  }
+}
diff --git a/karaf/webconsole/src/main/java/org/apache/felix/karaf/webconsole/FeaturesPlugin.java b/karaf/webconsole/src/main/java/org/apache/felix/karaf/webconsole/FeaturesPlugin.java
index 3351220..ad22929 100644
--- a/karaf/webconsole/src/main/java/org/apache/felix/karaf/webconsole/FeaturesPlugin.java
+++ b/karaf/webconsole/src/main/java/org/apache/felix/karaf/webconsole/FeaturesPlugin.java
@@ -16,83 +16,538 @@
  */
 package org.apache.felix.karaf.webconsole;
 
+
 import java.io.IOException;
+import java.io.InputStream;
 import java.io.PrintWriter;
-import javax.servlet.Servlet;
+import java.net.URI;
+import java.net.URL;
+import java.util.Arrays;
+import java.util.Comparator;
+
 import javax.servlet.ServletException;
 import javax.servlet.http.HttpServletRequest;
 import javax.servlet.http.HttpServletResponse;
+
+import org.apache.commons.logging.Log;
+import org.apache.commons.logging.LogFactory;
 import org.apache.felix.karaf.gshell.features.FeaturesService;
+import org.apache.felix.karaf.gshell.features.Repository;
 import org.apache.felix.webconsole.AbstractWebConsolePlugin;
 
-/**
- * Felix Web Console plugin to interact with Karaf features
- *
- * @author Marcin Wilkos
- */
-public class FeaturesPlugin extends AbstractWebConsolePlugin implements Servlet {
+import org.json.JSONException;
+import org.json.JSONWriter;
 
-    private static final String LABEL = "features";
-    private static final String TITLE = "Features";
-    
+import org.osgi.framework.BundleContext;
+
+
+/**
+ * The <code>FeaturesPlugin</code>
+ */
+public class FeaturesPlugin extends AbstractWebConsolePlugin
+{
+
+    /** Pseudo class version ID to keep the IDE quite. */
+    private static final long serialVersionUID = 1L;
+
+    public static final String NAME = "features";
+
+    public static final String LABEL = "Features";
+
+    private Log log = LogFactory.getLog(FeaturesPlugin.class);
+
+    private ClassLoader classLoader;
+
+    private String featuresJs = "/features/res/ui/features.js";
+
     private FeaturesService featuresService;
     
-    public FeaturesPlugin() {
-        super();
+    private BundleContext bundleContext;
+
+
+    /*
+     * Blueprint lifecycle callback methods
+     */
+    
+    public void start()
+    {
+        super.activate( bundleContext );
+
+        this.classLoader = this.getClass().getClassLoader();
+
+        this.log.info( LABEL + " plugin activated" );
     }
 
-    public String getTitle() {
-        return TITLE;
+    public void stop()
+    {
+        this.log.info( LABEL + " plugin deactivated" );
+        super.deactivate();
     }
 
-    public String getLabel() {
+    //
+    // AbstractWebConsolePlugin interface
+    //    
+    public String getLabel()
+    {
+        return NAME;
+    }
+
+
+    public String getTitle()
+    {
         return LABEL;
     }
 
-    protected void renderContent(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException {
-        PrintWriter pw = response.getWriter();
-        pw.println("<pre>");
-        pw.println("</pre>");
 
-        pw.println( "<table class='content' cellpadding='0' cellspacing='0' width='100%'>" );
+    protected void doPost( HttpServletRequest req, HttpServletResponse resp ) throws ServletException, IOException
+    {
+        boolean success = false;
 
-        pw.println( "<tr class='content'>" );
-        pw.println( "<th class='content container'>" + getTitle() + "</th>" );
-        pw.println( "</tr>" );
+        final String action = req.getParameter( "action" );
+        final String feature = req.getParameter( "feature" );
+        final String version = req.getParameter( "version" );
+        final String url = req.getParameter( "url" );
 
-        pw.println( "<tr class='content'>" );
-        pw.println( "<td class='content'>" );
-        pw.println( "<pre>" );
-
-        pw.println("*** Features:");
-        String[] features;
-        try {
-            features = getFeatures();
-        } catch (Exception e) {
-            throw new ServletException("Unable to fetch features", e);
+        if ( action == null )
+        {
+            success = true;
         }
-        for(int i=0; i<features.length;i++){
-            pw.println(features[i]);
+        else if ( "installFeature".equals( action ) )
+        {
+            success = this.installFeature(feature, version);
+        }
+        else if ( "uninstallFeature".equals( action ) )
+        {
+            success = this.uninstallFeature( feature, version );
+        }
+        else if ( "refreshRepository".equals( action ) )
+        {
+            success = this.refreshRepository( url );
+        }
+        else if ( "removeRepository".equals( action ) )
+        {
+            success = this.removeRepository( url );
+        }
+        else if ( "addRepository".equals( action ) )
+        {
+            success = this.addRepository( url );
         }
 
-        pw.println( "</pre>" );
-        pw.println( "</td>" );
-        pw.println( "</tr>" );
-        pw.println( "</table>" );
+        if ( success )
+        {
+            // let's wait a little bit to give the framework time
+            // to process our request
+            try
+            {
+                Thread.sleep( 800 );
+            }
+            catch ( InterruptedException e )
+            {
+                // we ignore this
+            }
+            this.renderJSON( resp, null );
+        }
+        else
+        {
+            super.doPost( req, resp );
+        }
     }
 
-    /*
-     * Get the list of installed/uninstalled features
-     */
-    private String[] getFeatures() throws Exception {        
-        return getFeaturesService().listFeatures();
+
+    protected void renderContent( HttpServletRequest request, HttpServletResponse response ) throws IOException
+    {
+
+        // get request info from request attribute
+        final PrintWriter pw = response.getWriter();
+
+        String appRoot = ( String ) request
+            .getAttribute( "org.apache.felix.webconsole.internal.servlet.OsgiManager.appRoot" );
+        final String featuresScriptTag = "<script src='" + appRoot + this.featuresJs
+            + "' language='JavaScript'></script>";
+        pw.println( featuresScriptTag );
+
+        pw.println( "<script type='text/javascript'>" );
+        pw.println( "// <![CDATA[" );
+        pw.println( "var imgRoot = '" + appRoot + "/res/imgs';" );
+        pw.println( "// ]]>" );
+        pw.println( "</script>" );
+
+        pw.println( "<div id='plugin_content'/>" );
+
+        pw.println( "<script type='text/javascript'>" );
+        pw.println( "// <![CDATA[" );
+        pw.print( "renderFeatures( " );
+        writeJSON( pw );
+        pw.println( " )" );
+        pw.println( "// ]]>" );
+        pw.println( "</script>" );
+    }
+
+
+    //
+    // Additional methods
+    //
+
+    protected URL getResource( String path )
+    {
+        path = path.substring( NAME.length() + 1 );
+        URL url = this.classLoader.getResource( path );
+        try
+        {
+            InputStream ins = url.openStream();
+            if ( ins == null )
+            {
+                this.log.error( "failed to open " + url );
+            }
+        }
+        catch ( IOException e )
+        {
+            this.log.error( e.getMessage(), e );
+        }
+        return url;
+    }
+
+
+    private boolean installFeature(String feature, String version) {
+        boolean success = false;
+        if ( featuresService == null )
+        {
+            this.log.error( "GShell Features service is unavailable." );
+        }
+        try
+        {
+            featuresService.installFeature( feature, version );
+            success = true;
+        }
+        catch ( Exception e )
+        {
+            this.log.error( "failed to install feature: ", e );
+        }
+        return success;
+    }
+
+
+    private boolean uninstallFeature(String feature, String version) {
+        boolean success = false;
+        if ( featuresService == null )
+        {
+            this.log.error( "GShell Features service is unavailable." );
+        }
+        try
+        {
+            featuresService.uninstallFeature( feature, version );
+            success = true;
+        }
+        catch ( Exception e )
+        {
+            this.log.error( "failed to install feature: ", e );
+        }
+        return success;
+    }
+
+
+    private boolean removeRepository(String url) {
+        boolean success = false;
+        if ( featuresService == null )
+        {
+            this.log.error( "GShell Features service is unavailable." );
+        }
+        try
+        {
+            featuresService.removeRepository( new URI( url ) );
+            success = true;
+        }
+        catch ( Exception e )
+        {
+            this.log.error( "failed to install feature: ", e );
+        }
+        return success;
+    }
+
+
+    private boolean refreshRepository(String url) {
+        boolean success = false;
+        if ( featuresService == null )
+        {
+            this.log.error( "GShell Features service is unavailable." );
+        }
+        try
+        {
+            featuresService.removeRepository( new URI( url ) );
+            featuresService.addRepository( new URI( url ) );
+            success = true;
+        }
+        catch ( Exception e )
+        {
+            this.log.error( "failed to install feature: ", e );
+        }
+        return success;
+    }
+
+
+    private boolean addRepository(String url) {
+        boolean success = false;
+        if ( featuresService == null )
+        {
+            this.log.error( "GShell Features service is unavailable." );
+        }
+        try
+        {
+            featuresService.addRepository( new URI( url ) );
+            success = true;
+        }
+        catch ( Exception e )
+        {
+            this.log.error( "failed to install feature: ", e );
+        }
+        return success;
+    }
+
+
+    private void renderJSON( final HttpServletResponse response, final String feature ) throws IOException
+    {
+        response.setContentType( "application/json" );
+        response.setCharacterEncoding( "UTF-8" );
+
+        final PrintWriter pw = response.getWriter();
+        writeJSON( pw );
+    }
+
+
+    private void writeJSON( final PrintWriter pw ) throws IOException
+    {
+        final Feature[] features = this.getFeatures();
+        final String statusLine = this.getStatusLine( features );
+        final String[] repositories = this.getRepositories();
+
+        final JSONWriter jw = new JSONWriter( pw );
+
+        try
+        {
+            jw.object();
+
+            jw.key( "status" );
+            jw.value( statusLine );
+
+            jw.key( "features" );
+            jw.array();
+            for ( int i = 0; i < features.length; i++ )
+            {
+                featureInfo( jw, features[i] );
+            }
+            jw.endArray();
+
+            jw.key( "repositories" );
+            jw.array();
+            for ( int i = 0; i < repositories.length; i++ )
+            {
+                jw.object();
+                jw.key( "url" );
+                jw.value( repositories[i] );
+                jw.key( "actions" );
+                jw.array();
+                action( jw, true, "refreshRepository", "Refresh", "refresh" );
+                action( jw, true, "removeRepository", "Uninstall", "delete" );
+                jw.endArray();
+                jw.endObject();
+            }
+            jw.endArray();
+
+            jw.endObject();
+
+        }
+        catch ( JSONException je )
+        {
+            throw new IOException( je.toString() );
+        }
+
+    }
+
+
+    private String[] getRepositories()
+    {
+        String[] repositories = new String[0];
+
+        if ( featuresService == null )
+        {
+            this.log.error( "GShell Features service is unavailable." );
+            return repositories;
+        }
+
+        Repository[] repositoryInfo = null;
+        try
+        {
+            repositoryInfo = featuresService.listRepositories();
+        }
+        catch ( Exception e )
+        {
+            this.log.error( e.getMessage() );
+            return new String[0];
+        }
+
+        repositories = new String[repositoryInfo.length];
+        for ( int i = 0; i < repositoryInfo.length; i++ )
+        {
+            repositories[i] = repositoryInfo[i].getURI().toString();
+        }
+        return repositories;
+    }
+
+
+    private Feature[] getFeatures()
+    {
+        Feature[] features = new Feature[0];
+
+        if ( featuresService == null )
+        {
+            this.log.error( "GShell Features service is unavailable." );
+            return features;
+        }
+
+        String[] featureInfo = null;
+        try
+        {
+            featureInfo = featuresService.listFeatures();
+        }
+        catch ( Exception e )
+        {
+            this.log.error( e.getMessage() );
+            return new Feature[0];
+        }
+
+        features = new Feature[featureInfo.length];
+        for ( int i = 0; i < featureInfo.length; i++ )
+        {
+            String[] temp;
+            temp = getBracketedToken( featureInfo[i], 0 );
+            Feature.State state;
+            if ( "installed  ".equals( temp[0] ) )
+            {
+                state = Feature.State.INSTALLED;
+            }
+            else if ( "uninstalled".equals( temp[0] ) )
+            {
+                state = Feature.State.UNINSTALLED;
+            }
+            else
+            {
+                state = Feature.State.UNKNOWN;
+            }
+            temp = getBracketedToken( temp[1], 0 );
+            String version = temp[0];
+            features[i] = new Feature( temp[1].trim(), version, state );
+        }
+        Arrays.sort( features, new FeatureComparator() );
+        return features;
+    }
+
+    private String[] getBracketedToken( String str, int startIndex )
+    {
+        int start = str.indexOf( '[', startIndex ) + 1;
+        int end = str.indexOf( ']', start );
+        String token = str.substring( start, end );
+        String remainder = str.substring( end + 1 );
+        return new String[]
+            { token, remainder };
+    }
+
+
+    class FeatureComparator implements Comparator<Feature>
+    {
+        public int compare( Feature o1, Feature o2 )
+        {
+            return o1.name.toLowerCase().compareTo( o2.name.toLowerCase() );
+        }
+    }
+
+
+    private String getStatusLine( final Feature[] features )
+    {
+        int installed = 0;
+        for ( int i = 0; i < features.length; i++ )
+        {
+            if ( features[i].state == Feature.State.INSTALLED )
+            {
+                installed++;
+            }
+        }
+        final StringBuffer buffer = new StringBuffer();
+        buffer.append( "Feature information: " );
+        appendFeatureInfoCount( buffer, "in total", features.length );
+        if ( installed == features.length )
+        {
+            buffer.append( " - all " );
+            appendFeatureInfoCount( buffer, "active.", features.length );
+        }
+        else
+        {
+            if ( installed != 0 )
+            {
+                buffer.append( ", " );
+                appendFeatureInfoCount( buffer, "installed", installed );
+            }
+            buffer.append( '.' );
+        }
+        return buffer.toString();
+    }
+
+
+    private void appendFeatureInfoCount( final StringBuffer buf, String msg, int count )
+    {
+        buf.append( count );
+        buf.append( " feature" );
+        if ( count != 1 )
+            buf.append( 's' );
+        buf.append( ' ' );
+        buf.append( msg );
+    }
+
+
+    private void featureInfo( JSONWriter jw, Feature feature ) throws JSONException
+    {
+        jw.object();
+        jw.key( "name" );
+        jw.value( feature.name );
+        jw.key( "version" );
+        jw.value( feature.version );
+        jw.key( "state" );
+        jw.value( feature.state );
+
+        jw.key( "actions" );
+        jw.array();
+
+        if ( feature.state == Feature.State.INSTALLED )
+        {
+            action( jw, true, "uninstallFeature", "Uninstall", "delete" );
+        }
+        else
+        {
+            action( jw, true, "installFeature", "Install", "start" );
+        }
+        jw.endArray();
+
+        jw.endObject();
+    }
+
+
+    private void action( JSONWriter jw, boolean enabled, String op, String title, String image ) throws JSONException
+    {
+        jw.object();
+        jw.key( "enabled" ).value( enabled );
+        jw.key( "op" ).value( op );
+        jw.key( "title" ).value( title );
+        jw.key( "image" ).value( image );
+        jw.endObject();
     }
     
-    public FeaturesService getFeaturesService() {
-        return featuresService;
-    }
-    
-    public void setFeaturesService(FeaturesService featuresService) {
+    // DI setters
+    public void setFeaturesService(FeaturesService featuresService) 
+    {
         this.featuresService = featuresService;
     }
+    
+    public void setBundleContext(BundleContext bundleContext) 
+    {
+        this.bundleContext = bundleContext;
+    }
 }
diff --git a/karaf/webconsole/src/main/resources/OSGI-INF/blueprint/webconsole.xml b/karaf/webconsole/src/main/resources/OSGI-INF/blueprint/webconsole.xml
index 7f3d106..b564a35 100644
--- a/karaf/webconsole/src/main/resources/OSGI-INF/blueprint/webconsole.xml
+++ b/karaf/webconsole/src/main/resources/OSGI-INF/blueprint/webconsole.xml
@@ -22,8 +22,9 @@
 
     <reference id="featuresService" interface="org.apache.felix.karaf.gshell.features.FeaturesService" />
 
-    <bean id="featuresPlugin" class="org.apache.felix.karaf.webconsole.FeaturesPlugin">
+    <bean id="featuresPlugin" class="org.apache.felix.karaf.webconsole.FeaturesPlugin" init-method="start" destroy-method="stop">
         <property name="featuresService" ref="featuresService" />
+        <property name="bundleContext" ref="blueprintBundleContext" />
     </bean>
 
     <service ref="featuresPlugin" interface="javax.servlet.Servlet" >
diff --git a/karaf/webconsole/src/main/resources/res/ui/features.js b/karaf/webconsole/src/main/resources/res/ui/features.js
new file mode 100644
index 0000000..eef0b4d
--- /dev/null
+++ b/karaf/webconsole/src/main/resources/res/ui/features.js
@@ -0,0 +1,184 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+function renderFeatures( data ) {
+    $(document).ready( function() {
+        renderView();
+        renderData( data );
+        $("#repository_table").tablesorter( {
+            headers: {
+                1: { sorter: false }
+            },
+            sortList: [[0,0]],
+        } );
+        $("#feature_table").tablesorter( {
+            headers: {
+                3: { sorter: false }
+            },
+            sortList: [[0,0]],
+        } );
+    } );
+}
+
+function renderView() {
+    renderStatusLine();
+    renderTable( "Feature Repositories", "repository_table", ["URL", "Actions"] );
+    var txt = "<form method='post'><div class='table'><table class='tablelayout'><tbody><tr>" +
+        "<input type='hidden' name='action' value='addRepository'/>" +
+        "<td><input id='url' type='text' name='url' style='width:100%'/></td>" +
+        "<td class='col_Actions'><input type='button' value='Add URL' onclick='addRepositoryUrl()'/></td>" +
+        "</tr></tbody></table></div></form><br/>";
+    $("#plugin_content").append( txt );
+    renderTable( "Features", "feature_table", ["Name", "Version", "Status", "Actions"] );
+    renderStatusLine();
+}
+
+function addRepositoryUrl() {
+    var url = document.getElementById( "url" ).value;
+    changeRepositoryState( "addRepository", url );
+}
+
+function renderStatusLine() {
+    $("#plugin_content").append( "<div class='fullwidth'><div class='statusline'/></div>" );
+}
+
+function renderTable( /* String */ title, /* String */ id, /* array of Strings */ columns ) {
+    var txt = "<div class='table'><table class='tablelayout'><tbody><tr>" +
+        "<td style='color:#6181A9;background-color:#e6eeee'>" +
+        title + "</td></tr></tbody></table>" +
+        "<table id='" + id + "' class='tablelayout'><thead><tr>";
+    for ( var name in columns ) {
+      txt = txt + "<th class='col_" + columns[name] + "' style='border-top:#e6eeee'>" + columns[name] + "</th>";
+    }
+    txt = txt + "</tr></thead><tbody></tbody></table></div>";
+    $("#plugin_content").append( txt );
+}
+
+function renderData( /* Object */ data ) {
+    renderStatusData( data.status );
+    renderRepositoryTableData( data.repositories );
+    renderFeatureTableData( data.features );
+}
+
+function renderStatusData( /* String */ status )  {
+    $(".statusline").empty().append( status );
+}
+
+function renderRepositoryTableData( /* array of Objects */ repositories ) {
+    var trElement;
+    var input;
+    $("#repository_table > tbody > tr").remove();
+    for ( var idx in repositories ) {
+        trElement = tr( null, { id: "repository-" + idx } );
+        renderRepositoryData( trElement, repositories[idx] );
+        $("#repository_table > tbody").append( trElement ); 
+    }
+    $("#repository_table").trigger( "update" );
+}
+
+function renderRepositoryData( /* Element */ parent, /* Object */ repository ) {
+    parent.appendChild( td( null, null, [text( repository.url )] ) );
+
+    var actionsTd = td( null, null );
+    var div = createElement( "div", null, {
+      style: { "text-align": "left"}
+    } );
+    actionsTd.appendChild( div );
+    
+    for ( var a in repository.actions ) {
+      repositoryButton( div, repository.url, repository.actions[a] );
+    }
+    parent.appendChild( actionsTd );
+}
+
+function repositoryButton( /* Element */ parent, /* String */ url, /* Obj */ action ) {
+    if ( !action.enabled ) {
+        return;
+    }
+  
+    var input = createElement( "input", null, {
+        type: 'image',
+        style: {"margin-left": "10px"},
+        title: action.title,
+        alt: action.title,
+        src: imgRoot + '/bundle_' + action.image + '.png'
+    } );
+    $(input).click( function() {changeRepositoryState( action.op, url )} );
+
+    if ( !action.enabled ) {
+        $(input).attr( "disabled", true );
+    }
+    parent.appendChild( input );
+}
+
+function changeRepositoryState( /* String */ action, /* String */ url ) {
+    $.post( pluginRoot, {"action": action, "url": url}, function( data ) {
+        renderData( data );
+    }, "json" ); 
+}
+
+function renderFeatureTableData( /* array of Objects */ features ) {
+    $("#feature_table > tbody > tr").remove();
+    for ( var idx in features ) {
+        var trElement = tr( null, { id: "feature-" + idx } );
+        renderFeatureData( trElement, features[idx] );
+        $("#feature_table > tbody").append( trElement ); 
+    }
+    $("#feature_table").trigger( "update" );
+}
+
+function renderFeatureData( /* Element */ parent, /* Object */ feature ) {
+    parent.appendChild( td( null, null, [ text( feature.name ) ] ) );
+    parent.appendChild( td( null, null, [ text( feature.version ) ] ) );
+    parent.appendChild( td( null, null, [ text( feature.state ) ] ) );
+    var actionsTd = td( null, null );
+    var div = createElement( "div", null, {
+        style: { "text-align": "left"}
+    } );
+    actionsTd.appendChild( div );
+    
+    for ( var a in feature.actions ) {
+        featureButton( div, feature.name, feature.version, feature.actions[a] );
+    }
+    parent.appendChild( actionsTd );
+}
+
+function featureButton( /* Element */ parent, /* String */ name, /* String */ version, /* Obj */ action ) {
+    if ( !action.enabled ) {
+        return;
+    }
+  
+    var input = createElement( "input", null, {
+        type: 'image',
+        style: {"margin-left": "10px"},
+        title: action.title,
+        alt: action.title,
+        src: imgRoot + '/bundle_' + action.image + '.png'
+    } );
+    $(input).click( function() {changeFeatureState( action.op, name, version )} );
+
+    if ( !action.enabled ) {
+        $(input).attr( "disabled", true );
+    }
+    parent.appendChild( input );
+}
+
+function changeFeatureState( /* String */ action, /* String */ feature, /* String */ version ) {
+    $.post( pluginRoot, {"action": action, "feature": feature, "version": version}, function( data ) {
+        renderData( data );
+    }, "json" ); 
+}