Repository: jhipster/jhipster-control-center (ts)

Issue:
Feature: configuration management
Similar to http://localhost:1337/admin/jhi-configuration

We should be able to see the configuration of other applications

Pull request:
add feature configuration
Co-authored-by: Adil Abed <aelabedelalaoui@acm.org>

Fix #54 

Patch:
diff --git a/src/main/webapp/app/applications/configuration/configuration.component.ts b/src/main/webapp/app/applications/configuration/configuration.component.ts
new file mode 100644
index 0000000..a3dda0f
--- /dev/null
+++ b/src/main/webapp/app/applications/configuration/configuration.component.ts
@@ -0,0 +1,90 @@
+import { Component, Vue, Inject } from 'vue-property-decorator';
+import Vue2Filters from 'vue2-filters';
+import RoutesSelectorVue from '@/shared/routes/routes-selector.vue';
+import RoutesService, { Route } from '@/shared/routes/routes.service';
+import { takeUntil } from 'rxjs/operators';
+import { Subject } from 'rxjs';
+import ConfigurationService, { Bean, PropertySource } from './configuration.service';
+
+@Component({
+  components: {
+    'routes-selector': RoutesSelectorVue,
+  },
+  mixins: [Vue2Filters.mixin],
+})
+export default class JhiConfiguration extends Vue {
+  allBeans: Bean[] = [];
+  beans: Bean[] = [];
+  propertySources: PropertySource[] = [];
+
+  beansFilter = '';
+  beansAscending = true;
+  orderProp = 'name';
+
+  activeRoute: Route;
+  routes: Route[];
+  unsubscribe$ = new Subject();
+
+  @Inject('instanceConfigurationService') private instanceConfigurationService: () => ConfigurationService;
+  @Inject('routesService') private routesService: () => RoutesService;
+
+  /* istanbul ignore next */
+  public mounted(): void {
+    this.routesService()
+      .routeChanged$.pipe(takeUntil(this.unsubscribe$))
+      .subscribe(route => {
+        this.activeRoute = route;
+        this.refreshActiveRouteBeans();
+      });
+
+    this.routesService()
+      .routesChanged$.pipe(takeUntil(this.unsubscribe$))
+      .subscribe(routes => (this.routes = routes));
+  }
+
+  /** get beans and properties of active route */
+  refreshActiveRouteBeans(): void {
+    if (this.activeRoute) {
+      /* istanbul ignore next */
+      this.instanceConfigurationService()
+        .findBeans(this.activeRoute)
+        .pipe(takeUntil(this.unsubscribe$))
+        .subscribe(
+          beans => {
+            this.allBeans = beans;
+            this.filterAndSortBeans();
+          },
+          () => {
+            this.routesService().routeDown(this.activeRoute);
+          }
+        );
+
+      /* istanbul ignore next */
+      this.instanceConfigurationService()
+        .findPropertySources(this.activeRoute)
+        .pipe(takeUntil(this.unsubscribe$))
+        .subscribe(propertySources => (this.propertySources = propertySources));
+    } else {
+      this.routesService().routeDown(this.activeRoute);
+    }
+  }
+
+  filterAndSortBeans(): void {
+    /* istanbul ignore next */
+    this.beans = this.allBeans
+      .filter(bean => !this.beansFilter || bean.prefix.toLowerCase().includes(this.beansFilter.toLowerCase()))
+      .sort((a, b) => (a.prefix < b.prefix ? (this.beansAscending ? -1 : 1) : this.beansAscending ? 1 : -1));
+  }
+
+  public changeOrder(prop): void {
+    this.orderProp = prop;
+    this.beansAscending = !this.beansAscending;
+  }
+
+  /* istanbul ignore next */
+  beforeDestroy(): void {
+    // prevent memory leak when component destroyed
+    this.unsubscribe$.next();
+    this.unsubscribe$.complete();
+  }
+}
diff --git a/src/main/webapp/app/applications/configuration/configuration.service.ts b/src/main/webapp/app/applications/configuration/configuration.service.ts
new file mode 100644
index 0000000..4f837ee
--- /dev/null
+++ b/src/main/webapp/app/applications/configuration/configuration.service.ts
@@ -0,0 +1,98 @@
+import axios from 'axios';
+import { Route } from '@/shared/routes/routes.service';
+import { Observable } from 'rxjs';
+import { map } from 'rxjs/operators';
+import { SERVER_API_URL } from '@/constants';
+
+export interface ConfigProps {
+  contexts: Contexts;
+}
+
+export interface Contexts {
+  [key: string]: Context;
+}
+
+export interface Context {
+  beans: Beans;
+  parentId?: any;
+}
+
+export interface Beans {
+  [key: string]: Bean;
+}
+
+export interface Bean {
+  prefix: string;
+  properties: any;
+}
+
+export interface Env {
+  activeProfiles?: string[];
+  propertySources: PropertySource[];
+}
+
+export interface PropertySource {
+  name: string;
+  properties: Properties;
+}
+
+export interface Properties {
+  [key: string]: Property;
+}
+
+export interface Property {
+  value: string;
+  origin?: string;
+}
+
+export default class ConfigurationService {
+  /** return beans of a route */
+  public findBeans(route: Route): Observable<Bean[]> {
+    return Observable.create(observer => {
+      const beansControlCenter = (SERVER_API_URL !== undefined ? SERVER_API_URL : '') + '/management/configprops';
+      const beansOfAnInstance = 'gateway/' + route.path + '/management/configprops';
+      const url = route && route.path && route.path.length > 0 ? beansOfAnInstance : beansControlCenter;
+
+      axios
+        .get(url)
+        .then(res => {
+          observer.next(res.data);
+          observer.complete();
+        })
+        .catch(error => {
+          observer.error(error);
+        });
+    }).pipe(
+      map((configProps: ConfigProps) =>
+        Object.values(
+          Object.values(configProps.contexts)
+            .map(context => context.beans)
+            .reduce((allBeans: Beans, contextBeans: Beans) => ({ ...allBeans, ...contextBeans }))
+        )
+      )
+    );
+  }
+
+  /** return property sources of a route */
+  public findPropertySources(route: Route): Observable<PropertySource[]> {
+    return Observable.create(observer => {
+      const propertySourceControlCenter = (SERVER_API_URL !== undefined ? SERVER_API_URL : '') + '/management/env';
+      const propertySourceOfAnInstance = 'gateway/' + route.path + '/management/env';
+      const url = route && route.path && route.path.length > 0 ? propertySourceOfAnInstance : propertySourceControlCenter;
+
+      axios
+        .get(url)
+        .then(res => {
+          observer.next(res.data);
+          observer.complete();
+        })
+        .catch(error => {
+          observer.error(error);
+        });
+    }).pipe(
+      map((env: Env) => {
+        return env.propertySources;
+      })
+    );
+  }
+}
diff --git a/src/main/webapp/app/applications/configuration/configuration.vue b/src/main/webapp/app/applications/configuration/configuration.vue
new file mode 100644
index 0000000..a6ae86c
--- /dev/null
+++ b/src/main/webapp/app/applications/configuration/configuration.vue
@@ -0,0 +1,59 @@
+<template>
+    <div>
+        <div class="head">
+            <h2 id="configuration-page-heading" class="d-inline-block">Configuration</h2>
+            <routes-selector class="float-right refresh-left-side"></routes-selector>
+        </div>
+
+        <div v-if="allBeans && beans">
+        <span>Filter (by prefix)</span> <input type="text" v-model="beansFilter" v-on:input="filterAndSortBeans()" class="form-control">
+
+        <h3 id="spring-configuration">Spring configuration</h3>
+
+        <table class="table table-striped table-bordered table-responsive d-table table-dark" aria-describedby="spring-configuration">
+            <thead>
+                <tr>
+                    <th class="w-40" v-on:click="changeOrder('prefix')"><span class="mr-1">Prefix</span><font-awesome-icon icon="sort" /></th>
+                    <th class="w-60" v-on:click="changeOrder('properties')"><span class="mr-1">Properties</span><font-awesome-icon icon="sort" /></th>
+                </tr>
+            </thead>
+            <tbody>
+                <tr v-for="(bean, key, index) in orderBy(beans, orderProp, beansAscending === true ? 1 : -1)" :key="index">
+                    <td><span>{{ bean.prefix }}</span></td>
+                    <td>
+                        <div class="row" v-for="(property, key, index) in bean.properties" :key="index">
+                            <div class="col-md-4">{{ key }}</div>
+                            <div class="col-md-8">
+                                <span class="float-right badge-dark break">{{ property }}</span>
+                            </div>
+                        </div>
+                    </td>
+                </tr>
+            </tbody>
+        </table>
+
+        <div v-for="(propertySource, key, index) in propertySources" :key="index">
+            <h4><span>{{ propertySource.name }}</span></h4>
+
+            <table class="table table-sm table-striped table-bordered table-responsive d-table table-dark">
+                <thead>
+                    <tr>
+                        <th scope="col" class="w-40">Property</th>
+                        <th scope="col" class="w-60">Value</th>
+                    </tr>
+                </thead>
+                <tbody>
+                    <tr v-for="(property, key, index) in propertySource.properties" :key="index">
+                        <td class="break">{{ key }}</td>
+                        <td class="break">
+                            <span class="float-right badge-dark break">{{ property.value }}</span>
+                        </td>
+                    </tr>
+                </tbody>
+            </table>
+        </div>
+        </div>
+    </div>
+</template>
+
+<script lang="ts" src="./configuration.component.ts"></script>
diff --git a/src/main/webapp/app/core/jhi-sidebar/jhi-sidebar.vue b/src/main/webapp/app/core/jhi-sidebar/jhi-sidebar.vue
index ab86245..93133f9 100644
--- a/src/main/webapp/app/core/jhi-sidebar/jhi-sidebar.vue
+++ b/src/main/webapp/app/core/jhi-sidebar/jhi-sidebar.vue
@@ -26,7 +26,7 @@
             <font-awesome-icon icon="heart" />
             <span>Health</span>
           </b-nav-item>
-          <b-nav-item to="/admin/jhi-configuration">
+          <b-nav-item to="/applications/configuration">
             <font-awesome-icon icon="list" />
             <span>Configuration</span>
           </b-nav-item>
diff --git a/src/main/webapp/app/main.ts b/src/main/webapp/app/main.ts
index a23c079..3eb7f5c 100644
--- a/src/main/webapp/app/main.ts
+++ b/src/main/webapp/app/main.ts
@@ -30,6 +30,7 @@ import LoggersService from '@/applications/loggers/loggers.service';
 import MetricService from '@/applications/metric/metric.service';
 import { ToastPlugin, ModalPlugin } from 'bootstrap-vue';
 import InstanceHealthService from '@/applications/health/health.service';
+import InstanceConfigurationService from '@/applications/configuration/configuration.service';
 // jhcc-custom end
 
 /* tslint:disable */
@@ -105,6 +106,7 @@ new Vue({
     loggersService: () => new LoggersService(),
     metricService: () => new MetricService(),
     instanceHealthService: () => new InstanceHealthService(),
+    instanceConfigurationService: () => new InstanceConfigurationService(),
     // jhcc-custom end
   },
   store,
diff --git a/src/main/webapp/app/router/index.ts b/src/main/webapp/app/router/index.ts
index 2bbf92a..8c036b6 100644
--- a/src/main/webapp/app/router/index.ts
+++ b/src/main/webapp/app/router/index.ts
@@ -20,6 +20,7 @@ const InstanceComponent = () => import('../applications/instance/instance.vue');
 const LoggersComponent = () => import('../applications/loggers/loggers.vue');
 const MetricComponent = () => import('../applications/metric/metric.vue');
 const HealthComponent = () => import('../applications/health/health.vue');
+const ConfigurationComponent = () => import('../applications/configuration/configuration.vue');
 // jhcc-custom end
 
 /* tslint:disable */
@@ -103,7 +104,13 @@ export default new Router({
       name: 'InstanceHealthComponent',
       component: HealthComponent,
       meta:  {authorities : [Authority.ADMIN]}
-    }
+    },
+    {
+      path: '/applications/configuration',
+      name: 'InstanceConfigurationComponent',
+      component: ConfigurationComponent,
+      meta:  {authorities : [Authority.ADMIN]}
+    },
     // jhcc-custom end
     // jhipster-needle-add-entity-to-router - JHipster will add entities to the router here
   ]


Test patch:
diff --git a/src/test/javascript/spec/app/applications/configuration/configuration.service.spec.ts b/src/test/javascript/spec/app/applications/configuration/configuration.service.spec.ts
new file mode 100644
index 0000000..1552d80
--- /dev/null
+++ b/src/test/javascript/spec/app/applications/configuration/configuration.service.spec.ts
@@ -0,0 +1,150 @@
+import { shallowMount, createLocalVue, Wrapper } from '@vue/test-utils';
+import ConfigurationService, { Bean, Beans } from '@/applications/configuration/configuration.service';
+import ConfigurationClass from '@/applications/configuration/configuration.component';
+import Configuration from '@/applications/configuration/configuration.vue';
+import * as config from '@/shared/config/config';
+import axios from 'axios';
+import RoutesService, { Route } from '@/shared/routes/routes.service';
+
+const localVue = createLocalVue();
+const mockedAxios: any = axios;
+config.initVueApp(localVue);
+const store = config.initVueXStore(localVue);
+jest.mock('axios', () => ({
+  get: jest.fn(),
+  post: jest.fn(),
+}));
+const jhcc_route = {
+  path: '',
+  predicate: '',
+  filters: [],
+  serviceId: 'JHIPSTER-CONTROL-CENTER',
+  instanceId: 'JHIPSTER-CONTROL-CENTER',
+  instanceUri: '',
+  order: 0,
+} as Route;
+const service_test_route = {
+  path: 'service-test/service-test:number',
+  predicate: '',
+  filters: [],
+  serviceId: 'service-test',
+  instanceId: 'service-test-instance',
+  instanceUri: '',
+  order: 0,
+} as Route;
+const routes: Route[] = [jhcc_route, service_test_route];
+
+const jhcc_beans = [
+  {
+    prefix: 'prefix-a',
+    properties: {},
+  },
+  {
+    prefix: 'prefix-b',
+    properties: {},
+  },
+] as Bean[];
+
+const service_test_beans = [
+  {
+    prefix: 'prefix-c',
+    properties: {},
+  },
+  {
+    prefix: 'prefix-d',
+    properties: {},
+  },
+] as Bean[];
+
+describe('Configuration Component', () => {
+  let wrapper: Wrapper<ConfigurationClass>;
+  let configuration: ConfigurationClass;
+
+  beforeEach(() => {
+    mockedAxios.get.mockReturnValue(Promise.resolve({}));
+    wrapper = shallowMount<ConfigurationClass>(Configuration, {
+      store,
+      localVue,
+      provide: {
+        instanceConfigurationService: () => new ConfigurationService(),
+        routesService: () => new RoutesService(store),
+      },
+    });
+    configuration = wrapper.vm;
+  });
+
+  describe('when component is mounted', () => {
+    it('should set all default values correctly', () => {
+      expect(configuration.beansFilter).toBe('');
+      expect(configuration.orderProp).toBe('name');
+      expect(configuration.beansAscending).toBe(true);
+    });
+
+    it('should refresh active route beans if it is jhcc', async () => {
+      // WHEN
+      configuration.activeRoute = jhcc_route;
+      configuration.refreshActiveRouteBeans();
+      await configuration.$nextTick();
+
+      // THEN
+      expect(mockedAxios.get).toHaveBeenCalledWith('/management/configprops');
+      expect(mockedAxios.get).toHaveBeenCalledWith('/management/env');
+    });
+
+    it('should refresh active route beans if it is a service', async () => {
+      // WHEN
+      configuration.activeRoute = service_test_route;
+      configuration.refreshActiveRouteBeans();
+      await configuration.$nextTick();
+
+      // THEN
+      expect(mockedAxios.get).toHaveBeenCalledWith('gateway/' + service_test_route.path + '/management/configprops');
+      expect(mockedAxios.get).toHaveBeenCalledWith('gateway/' + service_test_route.path + '/management/env');
+    });
+  });
+
+  it('should change order and invert reverse', () => {
+    // WHEN
+    configuration.changeOrder('test-order');
+
+    // THEN
+    expect(configuration.orderProp).toEqual('test-order');
+    expect(configuration.beansAscending).toBe(false);
+  });
+
+  it('should change filter and sort beans correctly of jhcc beans', () => {
+    configuration.allBeans = jhcc_beans;
+    configuration.routes = routes;
+    configuration.activeRoute = jhcc_route;
+    configuration.beansFilter = 'prefix-a';
+
+    // WHEN
+    configuration.filterAndSortBeans();
+
+    // THEN
+    expect(configuration.beans).toEqual([
+      {
+        prefix: 'prefix-a',
+        properties: {},
+      },
+    ] as Bean[]);
+  });
+
+  it('should change filter and sort beans correctly of a service', () => {
+    configuration.allBeans = service_test_beans;
+    configuration.routes = routes;
+    configuration.activeRoute = service_test_route;
+    configuration.beansFilter = 'prefix-d';
+
+    // WHEN
+    configuration.filterAndSortBeans();
+
+    // THEN
+    expect(configuration.beans).toEqual([
+      {
+        prefix: 'prefix-d',
+        properties: {},
+      },
+    ] as Bean[]);
+  });
+});