From 50229fbd288bbfd3052199b7314699ffb181b0b2 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Fran=C3=A7ois=20Agneray?= <francois.agneray@lam.fr>
Date: Tue, 22 Mar 2022 22:13:24 +0100
Subject: [PATCH] #27 => WIP

---
 .../app/admin/instance/components/index.ts    |   6 +-
 .../components/instance-form.component.html   |   8 +
 .../components/instance-form.component.ts     |   1 +
 .../instance-group-form.component.html        |  29 ++
 .../instance-group-form.component.ts          |  78 ++++
 .../instance-group-table.component.html       |  37 ++
 .../instance-group-table.component.ts         |  22 +
 .../edit-instance-group.component.html        |  33 ++
 .../edit-instance-group.component.ts          |  47 ++
 .../instance-group-list.component.html        |  33 ++
 .../instance-group-list.component.ts          |  40 ++
 .../containers/instance-list.component.html   |   8 +
 .../new-instance-group.component.html         |  33 ++
 .../new-instance-group.component.ts           |  36 ++
 .../admin/instance/instance-routing.module.ts |  11 +-
 .../actions/instance-group.actions.ts         |  25 +
 client/src/app/metamodel/effects/index.ts     |   2 +
 .../effects/instance-group.effects.ts         | 159 +++++++
 client/src/app/metamodel/metamodel.reducer.ts |   3 +
 client/src/app/metamodel/models/index.ts      |   1 +
 .../metamodel/models/instance-group.model.ts  |  19 +
 .../reducers/instance-group.reducer.ts        |  82 ++++
 .../selectors/instance-group.selector.ts      |  54 +++
 client/src/app/metamodel/services/index.ts    |   2 +
 .../services/instance-group.service.ts        |  67 +++
 conf-dev/create-db.sh                         |   2 +-
 server/app/dependencies.php                   |  10 +-
 server/app/routes.php                         |   2 +
 .../doctrine-proxy/__CG__AppEntityDataset.php | 430 +++++++++++++++++-
 .../__CG__AppEntityInstance.php               |  48 +-
 .../__CG__AppEntityInstanceGroup.php          | 250 ++++++++++
 server/src/Action/InstanceAction.php          |   1 +
 server/src/Action/InstanceGroupAction.php     | 125 +++++
 server/src/Action/InstanceGroupListAction.php | 113 +++++
 server/src/Action/InstanceListAction.php      |   1 +
 server/src/Entity/Instance.php                |  20 +-
 server/src/Entity/InstanceGroup.php           |  92 ++++
 37 files changed, 1906 insertions(+), 24 deletions(-)
 create mode 100644 client/src/app/admin/instance/components/instance-group-form.component.html
 create mode 100644 client/src/app/admin/instance/components/instance-group-form.component.ts
 create mode 100644 client/src/app/admin/instance/components/instance-group-table.component.html
 create mode 100644 client/src/app/admin/instance/components/instance-group-table.component.ts
 create mode 100644 client/src/app/admin/instance/containers/edit-instance-group.component.html
 create mode 100644 client/src/app/admin/instance/containers/edit-instance-group.component.ts
 create mode 100644 client/src/app/admin/instance/containers/instance-group-list.component.html
 create mode 100644 client/src/app/admin/instance/containers/instance-group-list.component.ts
 create mode 100644 client/src/app/admin/instance/containers/new-instance-group.component.html
 create mode 100644 client/src/app/admin/instance/containers/new-instance-group.component.ts
 create mode 100644 client/src/app/metamodel/actions/instance-group.actions.ts
 create mode 100644 client/src/app/metamodel/effects/instance-group.effects.ts
 create mode 100644 client/src/app/metamodel/models/instance-group.model.ts
 create mode 100644 client/src/app/metamodel/reducers/instance-group.reducer.ts
 create mode 100644 client/src/app/metamodel/selectors/instance-group.selector.ts
 create mode 100644 client/src/app/metamodel/services/instance-group.service.ts
 create mode 100644 server/doctrine-proxy/__CG__AppEntityInstanceGroup.php
 create mode 100644 server/src/Action/InstanceGroupAction.php
 create mode 100644 server/src/Action/InstanceGroupListAction.php
 create mode 100644 server/src/Entity/InstanceGroup.php

diff --git a/client/src/app/admin/instance/components/index.ts b/client/src/app/admin/instance/components/index.ts
index dda1856a..1927331f 100644
--- a/client/src/app/admin/instance/components/index.ts
+++ b/client/src/app/admin/instance/components/index.ts
@@ -9,8 +9,12 @@
 
 import { InstanceCardComponent } from './instance-card.component';
 import { InstanceFormComponent } from './instance-form.component';
+import { InstanceGroupTableComponent } from './instance-group-table.component';
+import { InstanceGroupFormComponent } from './instance-group-form.component';
 
 export const dummiesComponents = [
     InstanceCardComponent,
-    InstanceFormComponent
+    InstanceFormComponent,
+    InstanceGroupTableComponent,
+    InstanceGroupFormComponent
 ];
diff --git a/client/src/app/admin/instance/components/instance-form.component.html b/client/src/app/admin/instance/components/instance-form.component.html
index 9243f79a..e756ad55 100644
--- a/client/src/app/admin/instance/components/instance-form.component.html
+++ b/client/src/app/admin/instance/components/instance-form.component.html
@@ -24,6 +24,14 @@
                 [rootDirectoryIsLoaded]="rootDirectoryIsLoaded"
                 (loadRootDirectory)="loadRootDirectory.emit($event)">
             </app-data-path-form-control>
+            <div class="custom-control custom-radio custom-control-inline">
+                <input type="radio" class="custom-control-input" id="public" formControlName="public" [value]="true">
+                <label class="custom-control-label" for="public"><span class="fas fa-globe"></span> Public</label>
+            </div>
+            <div class="custom-control custom-radio custom-control-inline">
+                <input type="radio" class="custom-control-input" id="private" formControlName="public" [value]="false">
+                <label class="custom-control-label" for="private"><span class="fas fa-lock"></span> Private</label>
+            </div>
         </accordion-group>
         <accordion-group heading="Design" [isOpen]="true">
             <app-file-select-form-control
diff --git a/client/src/app/admin/instance/components/instance-form.component.ts b/client/src/app/admin/instance/components/instance-form.component.ts
index 7166cfe4..407768ea 100644
--- a/client/src/app/admin/instance/components/instance-form.component.ts
+++ b/client/src/app/admin/instance/components/instance-form.component.ts
@@ -30,6 +30,7 @@ export class InstanceFormComponent implements OnInit {
         description: new FormControl('', [Validators.required]),
         display: new FormControl('', [Validators.required]),
         data_path: new FormControl(''),
+        public: new FormControl(true, [Validators.required]),
         portal_logo: new FormControl(''),
         design_color: new FormControl('#7AC29A', [Validators.required]),
         design_background_color: new FormControl('#7AC29A', [Validators.required]),
diff --git a/client/src/app/admin/instance/components/instance-group-form.component.html b/client/src/app/admin/instance/components/instance-group-form.component.html
new file mode 100644
index 00000000..713ae9b9
--- /dev/null
+++ b/client/src/app/admin/instance/components/instance-group-form.component.html
@@ -0,0 +1,29 @@
+<form [formGroup]="form" (ngSubmit)="submit()" novalidate>
+    <div class="form-group">
+        <label for="role">Role</label>
+        <input type="text" class="form-control" id="role" name="role" formControlName="role">
+    </div>
+    <div class="form-group">
+        <div class="form-row h-100">
+            <div class="col-4">
+                <label for="instances">Available instances</label>
+                <select multiple class="form-control" name="availableInstances" #selectAvailableInstances>
+                    <option *ngFor="let instance of getAvailableInstances()" [value]="instance.name">{{instance.name}}</option>
+                </select>
+            </div>
+            <div class="col-2 text-center my-auto">
+                <button (click)="addInstances(selectAvailableInstances)" type="button" class="btn btn-dark mb-1">>>></button><br>
+                <button (click)="removeInstances(selectGroupInstances)" type="button" class="btn btn-dark"><<<</button>
+            </div>
+            <div class="col-4">
+                <label for="instance">Group's instance</label>
+                <select multiple class="form-control" #selectGroupInstances>
+                    <option *ngFor="let instance of instanceGroupInstances" [value]="instance">{{instance}}</option>
+                </select>
+            </div>
+        </div>
+    </div>
+    <div class="form-group">
+        <ng-content></ng-content>
+    </div>
+</form>
diff --git a/client/src/app/admin/instance/components/instance-group-form.component.ts b/client/src/app/admin/instance/components/instance-group-form.component.ts
new file mode 100644
index 00000000..a7dcd74e
--- /dev/null
+++ b/client/src/app/admin/instance/components/instance-group-form.component.ts
@@ -0,0 +1,78 @@
+/**
+ * This file is part of Anis Client.
+ *
+ * @copyright Laboratoire d'Astrophysique de Marseille / CNRS
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+
+import { Component, Input, Output, EventEmitter, OnInit } from '@angular/core';
+import { FormGroup, FormControl, Validators } from '@angular/forms';
+
+import { InstanceGroup, Instance } from 'src/app/metamodel/models';
+
+@Component({
+    selector: 'app-instance-group-form',
+    templateUrl: 'instance-group-form.component.html'
+})
+export class InstanceGroupFormComponent implements OnInit {
+    @Input() instanceGroup: InstanceGroup;
+    @Input() instanceList: Instance[];
+    @Output() onSubmit: EventEmitter<InstanceGroup> = new EventEmitter();
+
+    public instanceGroupInstances = [];
+    public form = new FormGroup({
+        role: new FormControl('', [Validators.required])
+    });
+
+    ngOnInit() {
+        if (this.instanceGroup) {
+            this.form.patchValue(this.instanceGroup);
+            this.instanceGroupInstances = [...this.instanceGroup.instances];
+        }
+    }
+
+    submit() {
+        if (this.instanceGroup) {
+            this.onSubmit.emit({
+                ...this.instanceGroup,
+                ...this.form.value,
+                instances: this.instanceGroupInstances
+            });
+        } else {
+            this.onSubmit.emit({
+                ...this.form.value,
+                instances: this.instanceGroupInstances
+            });
+        }
+    }
+
+    getAvailableInstances(): Instance[] {
+        return this.instanceList.filter(instance => !this.instanceGroupInstances.includes(instance.name));
+    }
+
+    addInstances(selectElement) {
+        let availableInstanceSelected = [];
+        for (var i = 0; i < selectElement.options.length; i++) {
+            const optionElement = selectElement.options[i];
+            if (optionElement.selected == true) {
+                availableInstanceSelected.push(optionElement.value);
+            }
+        }
+        this.instanceGroupInstances.push(...availableInstanceSelected);
+        this.form.markAsDirty();
+    }
+
+    removeInstances(selectElement) {
+        let instanceGroupInstancesSelected = [];
+        for (var i = 0; i < selectElement.options.length; i++) {
+            const optionElement = selectElement.options[i];
+            if (optionElement.selected == true) {
+                instanceGroupInstancesSelected.push(optionElement.value);
+            }
+        }
+        this.instanceGroupInstances = [...this.instanceGroupInstances.filter(instance => !instanceGroupInstancesSelected.includes(instance))]
+        this.form.markAsDirty();
+    }
+}
diff --git a/client/src/app/admin/instance/components/instance-group-table.component.html b/client/src/app/admin/instance/components/instance-group-table.component.html
new file mode 100644
index 00000000..ab148aca
--- /dev/null
+++ b/client/src/app/admin/instance/components/instance-group-table.component.html
@@ -0,0 +1,37 @@
+<div class="table-responsive">
+    <table class="table table-striped" aria-describedby="Group list">
+        <thead>
+            <tr>
+                <th scope="col">ID</th>
+                <th scope="col">Role</th>
+                <th scope="col">Instances</th>
+                <th scope="col">Actions</th>
+            </tr>
+        </thead>
+        <tbody>
+            <tr *ngFor="let instanceGroup of instanceGroupList">
+                <td class="align-middle">{{ instanceGroup.id }}</td>
+                <td class="align-middle">{{ instanceGroup.role }}</td>
+                <td class="align-middle">
+                    <span *ngIf="instanceGroup.instances.length < 1" class="badge badge-pill badge-warning">
+                        Empty
+                    </span>
+                    <span *ngFor="let instance of instanceGroup.instances" class="badge badge-pill badge-info mr-1">
+                        {{ instance }}
+                    </span>
+                </td>
+                <td class="align-middle">
+                    <a title="Edit this group" routerLink="edit-group/{{instanceGroup.id}}" class="btn btn-outline-primary">
+                        <span class="fas fa-edit"></span>
+                    </a>
+                    &nbsp;
+                    <app-delete-btn
+                        [type]="'instanceGroup'"
+                        [label]="instanceGroup.role"
+                        (confirm)="deleteInstanceGroup.emit(instanceGroup)">
+                    </app-delete-btn>
+                </td>
+            </tr>
+        </tbody>
+    </table>
+</div>
diff --git a/client/src/app/admin/instance/components/instance-group-table.component.ts b/client/src/app/admin/instance/components/instance-group-table.component.ts
new file mode 100644
index 00000000..e4982f0e
--- /dev/null
+++ b/client/src/app/admin/instance/components/instance-group-table.component.ts
@@ -0,0 +1,22 @@
+/**
+ * This file is part of Anis Client.
+ *
+ * @copyright Laboratoire d'Astrophysique de Marseille / CNRS
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+
+import { Component, Input, Output, ChangeDetectionStrategy, EventEmitter } from '@angular/core';
+
+import { InstanceGroup } from 'src/app/metamodel/models';
+
+@Component({
+    selector: 'app-instance-group-table',
+    templateUrl: 'instance-group-table.component.html',
+    changeDetection: ChangeDetectionStrategy.OnPush
+})
+export class InstanceGroupTableComponent {
+    @Input() instanceGroupList: InstanceGroup[];
+    @Output() deleteInstanceGroup: EventEmitter<InstanceGroup> = new EventEmitter();
+}
diff --git a/client/src/app/admin/instance/containers/edit-instance-group.component.html b/client/src/app/admin/instance/containers/edit-instance-group.component.html
new file mode 100644
index 00000000..99eb49d8
--- /dev/null
+++ b/client/src/app/admin/instance/containers/edit-instance-group.component.html
@@ -0,0 +1,33 @@
+<div class="container-fluid">
+    <nav aria-label="breadcrumb">
+        <ol class="breadcrumb">
+            <li class="breadcrumb-item">
+                <a routerLink="/admin/instance/instance-list">
+                    Instances
+                </a>
+            </li>
+            <li class="breadcrumb-item" aria-current="page">
+                <a routerLink="/admin/instance/group">
+                    Instance groups
+                </a>
+            </li>
+            <li *ngIf="instanceGroupListIsLoaded | async" class="breadcrumb-item active" aria-current="page">Edit instance group {{ (instanceGroup | async).role }}</li>
+        </ol>
+    </nav>
+</div>
+
+<div class="container">
+    <app-spinner *ngIf="(instanceGroupListIsLoading | async) || (instanceListIsLoading | async)"></app-spinner>
+
+    <div *ngIf="(instanceGroupListIsLoaded | async) && (instanceListIsLoaded | async)" class="row">
+        <div class="col-12">
+            <app-instance-group-form [instanceGroup]="instanceGroup | async" [instanceList]="instanceList | async" (onSubmit)="editInstanceGroup($event)" #formGroup>
+                <button [disabled]="!formGroup.form.valid || formGroup.form.pristine" type="submit" class="btn btn-primary">
+                    <span class="fa fa-database"></span> Update instance group information
+                </button>
+                &nbsp;
+                <a routerLink="/admin/instance/group" role="button" class="btn btn-danger">Cancel</a>
+            </app-instance-group-form>
+        </div>
+    </div>
+</div>
diff --git a/client/src/app/admin/instance/containers/edit-instance-group.component.ts b/client/src/app/admin/instance/containers/edit-instance-group.component.ts
new file mode 100644
index 00000000..47dd9caf
--- /dev/null
+++ b/client/src/app/admin/instance/containers/edit-instance-group.component.ts
@@ -0,0 +1,47 @@
+/**
+ * This file is part of Anis Client.
+ *
+ * @copyright Laboratoire d'Astrophysique de Marseille / CNRS
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+
+import { Component, OnInit } from '@angular/core';
+import { Observable } from 'rxjs';
+import { Store } from '@ngrx/store';
+
+import { InstanceGroup, Instance } from 'src/app/metamodel/models';
+import * as instanceSelector from 'src/app/metamodel/selectors/instance.selector';
+import * as instanceGroupActions from 'src/app/metamodel/actions/instance-group.actions';
+import * as instanceGroupSelector from 'src/app/metamodel/selectors/instance-group.selector';
+
+@Component({
+    selector: 'app-edit-instance-group',
+    templateUrl: 'edit-instance-group.component.html'
+})
+export class EditInstanceGroupComponent implements OnInit {
+    public instanceGroupListIsLoading: Observable<boolean>;
+    public instanceGroupListIsLoaded: Observable<boolean>;
+    public instanceGroup: Observable<InstanceGroup>;
+    public instanceListIsLoading: Observable<boolean>;
+    public instanceListIsLoaded: Observable<boolean>;
+    public instanceList: Observable<Instance[]>;
+
+    constructor(private store: Store<{ }>) {
+        this.instanceGroupListIsLoading = store.select(instanceGroupSelector.selectInstanceGroupListIsLoading);
+        this.instanceGroupListIsLoaded = store.select(instanceGroupSelector.selectInstanceGroupListIsLoaded);
+        this.instanceGroup = store.select(instanceGroupSelector.selectInstanceGroupByRouteId);
+        this.instanceListIsLoading = store.select(instanceSelector.selectInstanceListIsLoading);
+        this.instanceListIsLoaded = store.select(instanceSelector.selectInstanceListIsLoaded);
+        this.instanceList = store.select(instanceSelector.selectAllInstances);
+    }
+
+    ngOnInit() {
+        Promise.resolve(null).then(() => this.store.dispatch(instanceGroupActions.loadInstanceGroupList()));
+    }
+
+    editInstanceGroup(instanceGroup: InstanceGroup) {
+        this.store.dispatch(instanceGroupActions.editInstanceGroup({ instanceGroup }));
+    }
+}
diff --git a/client/src/app/admin/instance/containers/instance-group-list.component.html b/client/src/app/admin/instance/containers/instance-group-list.component.html
new file mode 100644
index 00000000..62a39da7
--- /dev/null
+++ b/client/src/app/admin/instance/containers/instance-group-list.component.html
@@ -0,0 +1,33 @@
+<div class="container-fluid">
+    <nav aria-label="breadcrumb">
+        <ol class="breadcrumb">
+            <li class="breadcrumb-item">
+                <a routerLink="/admin/instance/instance-list">
+                    Instances
+                </a>
+            </li>
+            <li class="breadcrumb-item active" aria-current="page">Handle instance groups</li>
+        </ol>
+    </nav>
+
+    <app-spinner *ngIf="instanceGroupListIsLoading | async"></app-spinner>
+
+    <ng-container *ngIf="instanceGroupListIsLoaded | async">
+        <div class="row">
+            <div class="col-12">
+                <button title="Add a new instance group" class="btn btn-outline-success float-right" routerLink="new-group">
+                    <span class="fas fa-plus"></span> New instance group
+                </button>
+            </div>
+        </div>
+
+        <div class="row mt-1">
+            <div class="col-12">
+                <app-instance-group-table 
+                    [instanceGroupList]="instanceGroupList | async" 
+                    (deleteInstanceGroup)="deleteInstanceGroup($event)">
+                </app-instance-group-table>
+            </div>
+        </div>
+    </ng-container>
+</div>
diff --git a/client/src/app/admin/instance/containers/instance-group-list.component.ts b/client/src/app/admin/instance/containers/instance-group-list.component.ts
new file mode 100644
index 00000000..a3f054a8
--- /dev/null
+++ b/client/src/app/admin/instance/containers/instance-group-list.component.ts
@@ -0,0 +1,40 @@
+/**
+ * This file is part of Anis Client.
+ *
+ * @copyright Laboratoire d'Astrophysique de Marseille / CNRS
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+
+import { Component, OnInit } from '@angular/core';
+import { Observable } from 'rxjs';
+import { Store } from '@ngrx/store';
+
+import { InstanceGroup } from 'src/app/metamodel/models';
+import * as instanceGroupActions from 'src/app/metamodel/actions/instance-group.actions';
+import * as instanceGroupSelector from 'src/app/metamodel/selectors/instance-group.selector';
+
+@Component({
+    selector: 'app-instance-group-list',
+    templateUrl: 'instance-group-list.component.html'
+})
+export class InstanceGroupListComponent implements OnInit {
+    public instanceGroupListIsLoading: Observable<boolean>;
+    public instanceGroupListIsLoaded: Observable<boolean>;
+    public instanceGroupList: Observable<InstanceGroup[]>;
+
+    constructor(private store: Store<{ }>) {
+        this.instanceGroupListIsLoading = store.select(instanceGroupSelector.selectInstanceGroupListIsLoading);
+        this.instanceGroupListIsLoaded = store.select(instanceGroupSelector.selectInstanceGroupListIsLoaded);
+        this.instanceGroupList = store.select(instanceGroupSelector.selectAllInstanceGroups);
+    }
+
+    ngOnInit() {
+        Promise.resolve(null).then(() => this.store.dispatch(instanceGroupActions.loadInstanceGroupList()));
+    }
+
+    deleteInstanceGroup(instanceGroup: InstanceGroup) {
+        this.store.dispatch(instanceGroupActions.deleteInstanceGroup({ instanceGroup }));
+    }
+}
diff --git a/client/src/app/admin/instance/containers/instance-list.component.html b/client/src/app/admin/instance/containers/instance-list.component.html
index ce9430bf..cc56107c 100644
--- a/client/src/app/admin/instance/containers/instance-list.component.html
+++ b/client/src/app/admin/instance/containers/instance-list.component.html
@@ -7,6 +7,14 @@
 </div>
 
 <div class="container">
+    <div class="btn-toolbar mb-3" role="toolbar" aria-label="Toolbar with button groups">
+        <div class="btn-group mr-2" role="group" aria-label="Second group">
+            <button routerLink="/admin/instance/instance-group" title="Handle instance groups" class="btn btn-outline-primary">
+                <span class="fas fa-users"></span> Handle instance groups
+            </button>
+        </div>
+    </div>
+
     <div class="row row-cols-1 row-cols-sm-2 row-cols-md-3">
         <div class="col mb-3" *ngFor="let instance of (instanceList | async)">
             <app-instance-card 
diff --git a/client/src/app/admin/instance/containers/new-instance-group.component.html b/client/src/app/admin/instance/containers/new-instance-group.component.html
new file mode 100644
index 00000000..e6af4e6c
--- /dev/null
+++ b/client/src/app/admin/instance/containers/new-instance-group.component.html
@@ -0,0 +1,33 @@
+<div class="container-fluid">
+    <nav aria-label="breadcrumb">
+        <ol class="breadcrumb">
+            <li class="breadcrumb-item">
+                <a routerLink="/admin/instance/instance-list">
+                    Instances
+                </a>
+            </li>
+            <li class="breadcrumb-item" aria-current="page">
+                <a routerLink="/admin/instance/group">
+                    Instance groups
+                </a>
+            </li>
+            <li class="breadcrumb-item active" aria-current="page">Add a new instance group</li>
+        </ol>
+    </nav>
+</div>
+
+<div class="container">
+    <app-spinner *ngIf="instanceListIsLoading | async"></app-spinner>
+
+    <div *ngIf="instanceListIsLoaded | async" class="row">
+        <div class="col-12">
+            <app-instance-group-form [instanceList]="instanceList | async" (onSubmit)="addNewInstanceGroup($event)" #formGroup>
+                <button [disabled]="!formGroup.form.valid || formGroup.form.pristine" type="submit" class="btn btn-primary">
+                    <span class="fa fa-database"></span> Add the new instance group
+                </button>
+                &nbsp;
+                <a routerLink="/admin/instance/instance-group" type="button" class="btn btn-danger">Cancel</a>
+            </app-instance-group-form>
+        </div>
+    </div>
+</div>
diff --git a/client/src/app/admin/instance/containers/new-instance-group.component.ts b/client/src/app/admin/instance/containers/new-instance-group.component.ts
new file mode 100644
index 00000000..48d2bc2f
--- /dev/null
+++ b/client/src/app/admin/instance/containers/new-instance-group.component.ts
@@ -0,0 +1,36 @@
+/**
+ * This file is part of Anis Client.
+ *
+ * @copyright Laboratoire d'Astrophysique de Marseille / CNRS
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+
+import { Component } from '@angular/core';
+import { Observable } from 'rxjs';
+import { Store } from '@ngrx/store';
+
+import { InstanceGroup, Instance } from 'src/app/metamodel/models';
+import * as instanceGroupActions from 'src/app/metamodel/actions/instance-group.actions';
+import * as instanceSelector from 'src/app/metamodel/selectors/instance.selector';
+
+@Component({
+    selector: 'app-new-instance-group',
+    templateUrl: 'new-instance-group.component.html'
+})
+export class NewInstanceGroupComponent {
+    public instanceListIsLoading: Observable<boolean>;
+    public instanceListIsLoaded: Observable<boolean>;
+    public instanceList: Observable<Instance[]>;
+
+    constructor(private store: Store<{ }>) {
+        this.instanceListIsLoading = store.select(instanceSelector.selectInstanceListIsLoading);
+        this.instanceListIsLoaded = store.select(instanceSelector.selectInstanceListIsLoaded);
+        this.instanceList = store.select(instanceSelector.selectAllInstances);
+    }
+
+    addNewInstanceGroup(instanceGroup: InstanceGroup) {
+        this.store.dispatch(instanceGroupActions.addInstanceGroup({ instanceGroup }));
+    }
+}
diff --git a/client/src/app/admin/instance/instance-routing.module.ts b/client/src/app/admin/instance/instance-routing.module.ts
index 7c160511..4e2503ca 100644
--- a/client/src/app/admin/instance/instance-routing.module.ts
+++ b/client/src/app/admin/instance/instance-routing.module.ts
@@ -14,11 +14,17 @@ import { InstanceListComponent } from './containers/instance-list.component';
 import { NewInstanceComponent } from './containers/new-instance.component';
 import { EditInstanceComponent } from './containers/edit-instance.component';
 import { ConfigureInstanceComponent } from './containers/configure-instance.component';
+import { InstanceGroupListComponent } from './containers/instance-group-list.component';
+import { NewInstanceGroupComponent } from './containers/new-instance-group.component';
+import { EditInstanceGroupComponent } from './containers/edit-instance-group.component';
 
 const routes: Routes = [
     { path: 'instance-list', component: InstanceListComponent },
     { path: 'new-instance', component: NewInstanceComponent },
     { path: 'edit-instance/:iname', component: EditInstanceComponent },
+    { path: 'instance-group', component: InstanceGroupListComponent },
+    { path: 'instance-group/new-group', component: NewInstanceGroupComponent },
+    { path: 'instance-group/edit-group/:id', component: EditInstanceGroupComponent },
     { path: 'configure-instance/:iname', component: ConfigureInstanceComponent, children: 
         [
             { path: '', redirectTo: 'dataset/dataset-list', pathMatch: 'full' },
@@ -42,5 +48,8 @@ export const routedComponents = [
     InstanceListComponent,
     NewInstanceComponent,
     EditInstanceComponent,
-    ConfigureInstanceComponent
+    ConfigureInstanceComponent,
+    InstanceGroupListComponent,
+    NewInstanceGroupComponent,
+    EditInstanceGroupComponent
 ];
diff --git a/client/src/app/metamodel/actions/instance-group.actions.ts b/client/src/app/metamodel/actions/instance-group.actions.ts
new file mode 100644
index 00000000..f5f97239
--- /dev/null
+++ b/client/src/app/metamodel/actions/instance-group.actions.ts
@@ -0,0 +1,25 @@
+/**
+ * This file is part of Anis Client.
+ *
+ * @copyright Laboratoire d'Astrophysique de Marseille / CNRS
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+
+import { createAction, props } from '@ngrx/store';
+ 
+import { InstanceGroup } from '../models';
+
+export const loadInstanceGroupList = createAction('[Metamodel] Load Instance Group List');
+export const loadInstanceGroupListSuccess = createAction('[Metamodel] Load Instance Group List Success', props<{ instanceGroups: InstanceGroup[] }>());
+export const loadInstanceGroupListFail = createAction('[Metamodel] Load Instance Group List Fail');
+export const addInstanceGroup = createAction('[Metamodel] Add Instance Group', props<{ instanceGroup: InstanceGroup }>());
+export const addInstanceGroupSuccess = createAction('[Metamodel] Add Instance Group Success', props<{ instanceGroup: InstanceGroup }>());
+export const addInstanceGroupFail = createAction('[Metamodel] Add Instance Group Fail');
+export const editInstanceGroup = createAction('[Metamodel] Edit Instance Group', props<{ instanceGroup: InstanceGroup }>());
+export const editInstanceGroupSuccess = createAction('[Metamodel] Edit Instance Group Success', props<{ instanceGroup: InstanceGroup }>());
+export const editInstanceGroupFail = createAction('[Metamodel] Edit Instance Group Fail');
+export const deleteInstanceGroup = createAction('[Metamodel] Delete Instance Group', props<{ instanceGroup: InstanceGroup }>());
+export const deleteInstanceGroupSuccess = createAction('[Metamodel] Delete Instance Group Success', props<{ instanceGroup: InstanceGroup }>());
+export const deleteInstanceGroupFail = createAction('[Metamodel] Delete Instance Group Fail');
diff --git a/client/src/app/metamodel/effects/index.ts b/client/src/app/metamodel/effects/index.ts
index 311be4c1..2be316f4 100644
--- a/client/src/app/metamodel/effects/index.ts
+++ b/client/src/app/metamodel/effects/index.ts
@@ -16,6 +16,7 @@ import { DatasetFamilyEffects } from './dataset-family.effects';
 import { DatasetEffects } from './dataset.effects';
 import { AttributeEffects } from './attribute.effects';
 import { GroupEffects } from './group.effects';
+import { InstanceGroupEffects } from './instance-group.effects';
 import { CriteriaFamilyEffects } from './criteria-family.effects';
 import { OutputCategoryEffects } from './output-category.effects';
 import { OutputFamilyEffects } from './output-family.effects';
@@ -34,6 +35,7 @@ export const metamodelEffects = [
     DatasetEffects,
     AttributeEffects,
     GroupEffects,
+    InstanceGroupEffects,
     CriteriaFamilyEffects,
     OutputCategoryEffects,
     OutputFamilyEffects,
diff --git a/client/src/app/metamodel/effects/instance-group.effects.ts b/client/src/app/metamodel/effects/instance-group.effects.ts
new file mode 100644
index 00000000..97892873
--- /dev/null
+++ b/client/src/app/metamodel/effects/instance-group.effects.ts
@@ -0,0 +1,159 @@
+/**
+ * This file is part of Anis Client.
+ *
+ * @copyright Laboratoire d'Astrophysique de Marseille / CNRS
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+
+import { Injectable } from '@angular/core';
+import { Router } from '@angular/router';
+
+import { Actions, createEffect, ofType } from '@ngrx/effects';
+import { of } from 'rxjs';
+import { map, tap, mergeMap, catchError } from 'rxjs/operators';
+import { ToastrService } from 'ngx-toastr';
+
+import * as instanceGroupActions from '../actions/instance-group.actions';
+import { InstanceGroupService } from '../services/instance-group.service';
+
+/**
+ * @class
+ * @classdesc Survey effects.
+ */
+@Injectable()
+export class InstanceGroupEffects {
+    /**
+     * Calls action to retrieve instance group list.
+     */
+    loadInstanceGroups$ = createEffect((): any =>
+        this.actions$.pipe(
+            ofType(instanceGroupActions.loadInstanceGroupList),
+            mergeMap(() => this.instanceGroupService.retrieveInstanceGroupList()
+                .pipe(
+                    map(instanceGroups => instanceGroupActions.loadInstanceGroupListSuccess({ instanceGroups })),
+                    catchError(() => of(instanceGroupActions.loadInstanceGroupListFail()))
+                )
+            )
+        )
+    );
+
+    /**
+     * Calls action to add a instanceGroup.
+     */
+    addInstanceGroup$ = createEffect((): any =>
+        this.actions$.pipe(
+            ofType(instanceGroupActions.addInstanceGroup),
+            mergeMap(action => this.instanceGroupService.addInstanceGroup(action.instanceGroup)
+                .pipe(
+                    map(instanceGroup => instanceGroupActions.addInstanceGroupSuccess({ instanceGroup })),
+                    catchError(() => of(instanceGroupActions.addInstanceGroupFail()))
+                )
+            )
+        )
+    );
+
+    /**
+     * Displays add instanceGroup success notification.
+     */
+    addInstanceGroupSuccess$ = createEffect(() =>
+        this.actions$.pipe(
+            ofType(instanceGroupActions.addInstanceGroupSuccess),
+            tap(() => {
+                this.router.navigateByUrl(`/admin/instance/instance-group`);
+                this.toastr.success('Instance group successfully added', 'The new instance group was added into the database')
+            })
+        ), { dispatch: false }
+    );
+
+    /**
+     * Displays add instanceGroup error notification.
+     */
+    addInstanceGroupFail$ = createEffect(() =>
+        this.actions$.pipe(
+            ofType(instanceGroupActions.addInstanceGroupFail),
+            tap(() => this.toastr.error('Failure to add instance group', 'The new instance group could not be added into the database'))
+        ), { dispatch: false }
+    );
+
+    /**
+     * Calls action to modify a instanceGroup.
+     */
+    editInstanceGroup$ = createEffect((): any =>
+        this.actions$.pipe(
+            ofType(instanceGroupActions.editInstanceGroup),
+            mergeMap(action => this.instanceGroupService.editInstanceGroup(action.instanceGroup)
+                .pipe(
+                    map(instanceGroup => instanceGroupActions.editInstanceGroupSuccess({ instanceGroup })),
+                    catchError(() => of(instanceGroupActions.editInstanceGroupFail()))
+                )
+            )
+        )
+    );
+
+    /**
+     * Displays edit instanceGroup success notification.
+     */
+    editInstanceGroupSuccess$ = createEffect(() =>
+        this.actions$.pipe(
+            ofType(instanceGroupActions.editInstanceGroupSuccess),
+            tap(() => {
+                this.router.navigateByUrl(`/admin/instance/instance-group`);
+                this.toastr.success('Instance group successfully edited', 'The existing instance group has been edited into the database')
+            })
+        ), { dispatch: false }
+    );
+
+    /**
+     * Displays edit instanceGroup error notification.
+     */
+    editInstanceGroupFail$ = createEffect(() =>
+        this.actions$.pipe(
+            ofType(instanceGroupActions.editInstanceGroupFail),
+            tap(() => this.toastr.error('Failure to edit instance group', 'The existing instance group could not be edited into the database'))
+        ), { dispatch: false }
+    );
+
+    /**
+     * Calls action to remove a instanceGroup.
+     */
+    deleteInstanceGroup$ = createEffect((): any =>
+        this.actions$.pipe(
+            ofType(instanceGroupActions.deleteInstanceGroup),
+            mergeMap(action => this.instanceGroupService.deleteInstanceGroup(action.instanceGroup.id)
+                .pipe(
+                    map(() => instanceGroupActions.deleteInstanceGroupSuccess({ instanceGroup: action.instanceGroup })),
+                    catchError(() => of(instanceGroupActions.deleteInstanceGroupFail()))
+                )
+            )
+        )
+    );
+
+    /**
+     * Displays delete instanceGroup success notification.
+     */
+    deleteInstanceGroupSuccess$ = createEffect(() =>
+        this.actions$.pipe(
+            ofType(instanceGroupActions.deleteInstanceGroupSuccess),
+            tap(() => this.toastr.success('Instance group successfully deleted', 'The existing instance group has been deleted'))
+        ), { dispatch: false }
+    );
+
+    /**
+     * Displays delete instanceGroup error notification.
+     */
+    deleteInstanceGroupFail$ = createEffect(() =>
+        this.actions$.pipe(
+            ofType(instanceGroupActions.deleteInstanceGroupFail),
+            tap(() => this.toastr.error('Failure to delete instance group', 'The existing instance group could not be deleted from the database'))
+        ), { dispatch: false }
+    );
+
+    constructor(
+        private actions$: Actions,
+        private instanceGroupService: InstanceGroupService,
+        private router: Router,
+        private toastr: ToastrService
+    ) {}
+}
diff --git a/client/src/app/metamodel/metamodel.reducer.ts b/client/src/app/metamodel/metamodel.reducer.ts
index d7da5059..2873d835 100644
--- a/client/src/app/metamodel/metamodel.reducer.ts
+++ b/client/src/app/metamodel/metamodel.reducer.ts
@@ -15,6 +15,7 @@ import * as table from './reducers/table.reducer';
 import * as column from './reducers/column.reducer';
 import * as survey from './reducers/survey.reducer';
 import * as group from './reducers/group.reducer';
+import * as instanceGroup from './reducers/instance-group.reducer';
 import * as dataset from './reducers/dataset.reducer';
 import * as datasetFamily from './reducers/dataset-family.reducer';
 import * as instance from './reducers/instance.reducer';
@@ -38,6 +39,7 @@ export interface State {
     column: column.State;
     survey: survey.State;
     group: group.State;
+    instanceGroup: instanceGroup.State;
     dataset: dataset.State;
     datasetFamily: datasetFamily.State;
     instance: instance.State;
@@ -57,6 +59,7 @@ const reducers = {
     column: column.columnReducer,
     survey: survey.surveyReducer,
     group: group.groupReducer,
+    instanceGroup: instanceGroup.instanceGroupReducer,
     dataset: dataset.datasetReducer,
     datasetFamily: datasetFamily.datasetFamilyReducer,
     instance: instance.instanceReducer,
diff --git a/client/src/app/metamodel/models/index.ts b/client/src/app/metamodel/models/index.ts
index 4bbbbc18..4144765a 100644
--- a/client/src/app/metamodel/models/index.ts
+++ b/client/src/app/metamodel/models/index.ts
@@ -10,6 +10,7 @@
 export * from './database.model';
 export * from './survey.model';
 export * from './group.model';
+export * from './instance-group.model';
 export * from './dataset.model';
 export * from './dataset-family.model';
 export * from './instance.model';
diff --git a/client/src/app/metamodel/models/instance-group.model.ts b/client/src/app/metamodel/models/instance-group.model.ts
new file mode 100644
index 00000000..48b3a147
--- /dev/null
+++ b/client/src/app/metamodel/models/instance-group.model.ts
@@ -0,0 +1,19 @@
+/**
+ * This file is part of Anis Client.
+ *
+ * @copyright Laboratoire d'Astrophysique de Marseille / CNRS
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+
+/**
+ * Interface for group.
+ *
+ * @interface Group
+ */
+export interface InstanceGroup {
+    id: number;
+    role: string;
+    instances: string[];
+}
diff --git a/client/src/app/metamodel/reducers/instance-group.reducer.ts b/client/src/app/metamodel/reducers/instance-group.reducer.ts
new file mode 100644
index 00000000..cef704ca
--- /dev/null
+++ b/client/src/app/metamodel/reducers/instance-group.reducer.ts
@@ -0,0 +1,82 @@
+/**
+ * This file is part of Anis Client.
+ *
+ * @copyright Laboratoire d'Astrophysique de Marseille / CNRS
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+
+import { createReducer, on } from '@ngrx/store';
+import { EntityState, EntityAdapter, createEntityAdapter } from '@ngrx/entity';
+
+import { InstanceGroup } from '../models';
+import * as instanceGroupActions from '../actions/instance-group.actions';
+
+/**
+ * Interface for instanceGroup state.
+ *
+ * @interface State
+ */
+export interface State extends EntityState<InstanceGroup> {
+    instanceGroupListIsLoading: boolean;
+    instanceGroupListIsLoaded: boolean;
+}
+
+export const adapter: EntityAdapter<InstanceGroup> = createEntityAdapter<InstanceGroup>();
+
+export const initialState: State = adapter.getInitialState({
+    instanceGroupListIsLoading: false,
+    instanceGroupListIsLoaded: false
+});
+
+export const instanceGroupReducer = createReducer(
+    initialState,
+    on(instanceGroupActions.loadInstanceGroupList, (state) => {
+        return {
+            ...state,
+            instanceGroupListIsLoading: true
+        }
+    }),
+    on(instanceGroupActions.loadInstanceGroupListSuccess, (state, { instanceGroups }) => {
+        return adapter.setAll(
+            instanceGroups,
+            {
+                ...state,
+                instanceGroupListIsLoading: false,
+                instanceGroupListIsLoaded: true
+            }
+        );
+    }),
+    on(instanceGroupActions.loadInstanceGroupListFail, (state) => {
+        return {
+            ...state,
+            instanceGroupListIsLoading: false
+        }
+    }),
+    on(instanceGroupActions.addInstanceGroupSuccess, (state, { instanceGroup }) => {
+        return adapter.addOne(instanceGroup, state)
+    }),
+    on(instanceGroupActions.editInstanceGroupSuccess, (state, { instanceGroup }) => {
+        return adapter.setOne(instanceGroup, state)
+    }),
+    on(instanceGroupActions.deleteInstanceGroupSuccess, (state, { instanceGroup }) => {
+        return adapter.removeOne(instanceGroup.id, state)
+    })
+);
+
+const {
+    selectIds,
+    selectEntities,
+    selectAll,
+    selectTotal,
+} = adapter.getSelectors();
+
+
+export const selectInstanceGroupIds = selectIds;
+export const selectInstanceGroupEntities = selectEntities;
+export const selectAllInstanceGroups = selectAll;
+export const selectInstanceGroupTotal = selectTotal;
+
+export const selectInstanceGroupListIsLoading = (state: State) => state.instanceGroupListIsLoading;
+export const selectInstanceGroupListIsLoaded = (state: State) => state.instanceGroupListIsLoaded;
diff --git a/client/src/app/metamodel/selectors/instance-group.selector.ts b/client/src/app/metamodel/selectors/instance-group.selector.ts
new file mode 100644
index 00000000..c691745e
--- /dev/null
+++ b/client/src/app/metamodel/selectors/instance-group.selector.ts
@@ -0,0 +1,54 @@
+/**
+ * This file is part of Anis Client.
+ *
+ * @copyright Laboratoire d'Astrophysique de Marseille / CNRS
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+
+import { createSelector } from '@ngrx/store';
+
+import * as reducer from '../metamodel.reducer';
+import * as fromInstanceGroup from '../reducers/instance-group.reducer';
+
+export const selectInstanceGroupState = createSelector(
+    reducer.getMetamodelState,
+    (state: reducer.State) => state.instanceGroup
+);
+
+export const selectInstanceGroupIds = createSelector(
+    selectInstanceGroupState,
+    fromInstanceGroup.selectInstanceGroupIds
+);
+
+export const selectInstanceGroupEntities = createSelector(
+    selectInstanceGroupState,
+    fromInstanceGroup.selectInstanceGroupEntities
+);
+
+export const selectAllInstanceGroups = createSelector(
+    selectInstanceGroupState,
+    fromInstanceGroup.selectAllInstanceGroups
+);
+
+export const selectInstanceGroupTotal = createSelector(
+    selectInstanceGroupState,
+    fromInstanceGroup.selectInstanceGroupTotal
+);
+
+export const selectInstanceGroupListIsLoading = createSelector(
+    selectInstanceGroupState,
+    fromInstanceGroup.selectInstanceGroupListIsLoading
+);
+
+export const selectInstanceGroupListIsLoaded = createSelector(
+    selectInstanceGroupState,
+    fromInstanceGroup.selectInstanceGroupListIsLoaded
+);
+
+export const selectInstanceGroupByRouteId = createSelector(
+    selectInstanceGroupEntities,
+    reducer.selectRouterState,
+    (entities, router) => entities[router.state.params.id]
+);
diff --git a/client/src/app/metamodel/services/index.ts b/client/src/app/metamodel/services/index.ts
index 407158e8..414036d9 100644
--- a/client/src/app/metamodel/services/index.ts
+++ b/client/src/app/metamodel/services/index.ts
@@ -12,6 +12,7 @@ import { TableService } from './table.service';
 import { ColumnService } from './column.service';
 import { SurveyService } from './survey.service';
 import { GroupService } from './group.service';
+import { InstanceGroupService } from './instance-group.service';
 import { DatasetService } from './dataset.service';
 import { DatasetFamilyService } from './dataset-family.service';
 import { InstanceService } from './instance.service';
@@ -30,6 +31,7 @@ export const metamodelServices = [
     ColumnService,
     SurveyService,
     GroupService,
+    InstanceGroupService,
     DatasetService,
     DatasetFamilyService,
     InstanceService,
diff --git a/client/src/app/metamodel/services/instance-group.service.ts b/client/src/app/metamodel/services/instance-group.service.ts
new file mode 100644
index 00000000..2fbc6485
--- /dev/null
+++ b/client/src/app/metamodel/services/instance-group.service.ts
@@ -0,0 +1,67 @@
+/**
+ * This file is part of Anis Client.
+ *
+ * @copyright Laboratoire d'Astrophysique de Marseille / CNRS
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+
+import { Injectable } from '@angular/core';
+import { HttpClient } from '@angular/common/http';
+
+import { Observable } from 'rxjs';
+
+import { InstanceGroup } from '../models';
+import { AppConfigService } from 'src/app/app-config.service';
+
+/**
+ * @class
+ * @classdesc InstanceGroup service.
+ */
+@Injectable()
+export class InstanceGroupService {
+    constructor(private http: HttpClient, private config: AppConfigService) { }
+
+    /**
+     * Retrieves instance group list
+     *
+     * @return Observable<InstanceGroup[]>
+     */
+    retrieveInstanceGroupList(): Observable<InstanceGroup[]> {
+        return this.http.get<InstanceGroup[]>(`${this.config.apiUrl}/instance-group`);
+    }
+
+    /**
+     * Adds a new instance group
+     *
+     * @param  {InstanceGroup} newInstanceGroup - The instance group.
+     *
+     * @return Observable<InstanceGroup>
+     */
+    addInstanceGroup(newInstanceGroup: InstanceGroup): Observable<InstanceGroup> {
+        return this.http.post<InstanceGroup>(`${this.config.apiUrl}/instance-group`, newInstanceGroup);
+    }
+
+    /**
+     * Modifies an instance group.
+     *
+     * @param  {InstanceGroup} instanceGroup - The instance group.
+     *
+     * @return Observable<InstanceGroup>
+     */
+    editInstanceGroup(instanceGroup: InstanceGroup): Observable<InstanceGroup> {
+        return this.http.put<InstanceGroup>(`${this.config.apiUrl}/instance-group/${instanceGroup.id}`, instanceGroup);
+    }
+
+    /**
+     * Removes an instance group.
+     *
+     * @param  {number} instanceGroupId - The instance Group ID.
+     *
+     * @return Observable<object>
+     */
+    deleteInstanceGroup(instanceGroupId: number): Observable<object> {
+        return this.http.delete(`${this.config.apiUrl}/instance-group/${instanceGroupId}`);
+    }
+}
diff --git a/conf-dev/create-db.sh b/conf-dev/create-db.sh
index 166ddda8..fe86c76e 100644
--- a/conf-dev/create-db.sh
+++ b/conf-dev/create-db.sh
@@ -60,7 +60,7 @@ curl -d '{"label":"Spectra graph","value":"spectra_graph","display":20,"select_n
 curl -d '{"label":"Test","dbname":"anis_test","dbtype":"pdo_pgsql","dbhost":"db","dbport":5432,"dblogin":"anis","dbpassword":"anis"}' --header 'Content-Type: application/json' -X POST http://localhost/database
 
 # Add default instance
-curl -d '{"name":"default","label":"Default instance","description":"Instance for the test","display":10,"data_path":"\/DEFAULT","portal_logo":"","design_color":"#7AC29A","design_background_color":"#ffffff","design_logo":"logo.png","design_favicon":"favicon.ico","home_component":"WelcomeComponent","home_component_config":{"home_component_text":"AstroNomical Information System","home_component_logo":"home_component_logo.png"},"samp_enabled":true,"search_by_criteria_allowed":true,"search_by_criteria_label":"Search","search_multiple_allowed":false,"search_multiple_label":"Search multiple","search_multiple_all_datasets_selected":false,"documentation_allowed":false,"documentation_label":"Documentation"}' --header 'Content-Type: application/json' -X POST http://localhost/instance
+curl -d '{"name":"default","label":"Default instance","description":"Instance for the test","display":10,"data_path":"\/DEFAULT","public":true,"portal_logo":"","design_color":"#7AC29A","design_background_color":"#ffffff","design_logo":"logo.png","design_favicon":"favicon.ico","home_component":"WelcomeComponent","home_component_config":{"home_component_text":"AstroNomical Information System","home_component_logo":"home_component_logo.png"},"samp_enabled":true,"search_by_criteria_allowed":true,"search_by_criteria_label":"Search","search_multiple_allowed":false,"search_multiple_label":"Search multiple","search_multiple_all_datasets_selected":false,"documentation_allowed":false,"documentation_label":"Documentation"}' --header 'Content-Type: application/json' -X POST http://localhost/instance
 
 # Add ANIS, SVOM and IRIS surveys
 curl -d '{"name":"anis_survey","label":"ANIS survey","description":"Survey used for testing","link":"https://anis.lam.fr","manager":"F. Agneray","id_database":1}' --header 'Content-Type: application/json' -X POST http://localhost/survey
diff --git a/server/app/dependencies.php b/server/app/dependencies.php
index 7eb29195..d59b3c2d 100644
--- a/server/app/dependencies.php
+++ b/server/app/dependencies.php
@@ -21,7 +21,7 @@ $container->set(SETTINGS, function () {
 $container->set('em', function (ContainerInterface $c) {
     $settings = $c->get(SETTINGS)['database'];
     $devMode = boolval($settings['dev_mode']);
-    $proxyDir = getcwd() . '/../doctrine-proxy';
+    $proxyDir = '/project/doctrine-proxy';
 
     if ($devMode) {
         $cache = \Doctrine\Common\Cache\Psr6\DoctrineProvider::wrap(
@@ -113,6 +113,14 @@ $container->set('App\Action\AdminFileExplorerAction', function (ContainerInterfa
     return new App\Action\AdminFileExplorerAction($c->get('settings')['data_path']);
 });
 
+$container->set('App\Action\InstanceGroupListAction', function (ContainerInterface $c) {
+    return new App\Action\InstanceGroupListAction($c->get('em'));
+});
+
+$container->set('App\Action\InstanceGroupAction', function (ContainerInterface $c) {
+    return new App\Action\InstanceGroupAction($c->get('em'));
+});
+
 $container->set('App\Action\SurveyListAction', function (ContainerInterface $c) {
     return new App\Action\SurveyListAction($c->get('em'));
 });
diff --git a/server/app/routes.php b/server/app/routes.php
index a8aa5536..6e137446 100644
--- a/server/app/routes.php
+++ b/server/app/routes.php
@@ -33,6 +33,8 @@ $app->group('', function (RouteCollectorProxy $group) {
 
 // Metamodel actions
 $app->group('', function (RouteCollectorProxy $group) {
+    $group->map([OPTIONS, GET, POST], '/instance-group', App\Action\InstanceGroupListAction::class);
+    $group->map([OPTIONS, GET, PUT, DELETE], '/instance-group/{id}', App\Action\InstanceGroupAction::class);
     $group->map([OPTIONS, GET, POST], '/survey', App\Action\SurveyListAction::class);
     $group->map([OPTIONS, GET, PUT, DELETE], '/survey/{name}', App\Action\SurveyAction::class);
     $group->map([OPTIONS, GET, POST], '/instance', App\Action\InstanceListAction::class);
diff --git a/server/doctrine-proxy/__CG__AppEntityDataset.php b/server/doctrine-proxy/__CG__AppEntityDataset.php
index 857d284e..30afd80c 100644
--- a/server/doctrine-proxy/__CG__AppEntityDataset.php
+++ b/server/doctrine-proxy/__CG__AppEntityDataset.php
@@ -67,10 +67,10 @@ class Dataset extends \App\Entity\Dataset implements \Doctrine\ORM\Proxy\Proxy
     public function __sleep()
     {
         if ($this->__isInitialized__) {
-            return ['__isInitialized__', 'name', 'tableRef', 'label', 'description', 'display', 'dataPath', 'config', 'public', 'survey', 'datasetFamily', 'attributes'];
+            return ['__isInitialized__', 'name', 'tableRef', 'label', 'description', 'display', 'dataPath', 'public', 'infoSurveyEnabled', 'infoSurveyLabel', 'coneSearchEnabled', 'coneSearchOpened', 'coneSearchColumnRa', 'coneSearchColumnDec', 'downloadEnabled', 'downloadOpened', 'downloadCsv', 'downloadAscii', 'downloadVo', 'downloadArchive', 'summaryEnabled', 'summaryOpened', 'serverLinkEnabled', 'serverLinkOpened', 'datatableEnabled', 'datatableOpened', 'datatableSelectableRows', 'survey', 'datasetFamily', 'attributes'];
         }
 
-        return ['__isInitialized__', 'name', 'tableRef', 'label', 'description', 'display', 'dataPath', 'config', 'public', 'survey', 'datasetFamily', 'attributes'];
+        return ['__isInitialized__', 'name', 'tableRef', 'label', 'description', 'display', 'dataPath', 'public', 'infoSurveyEnabled', 'infoSurveyLabel', 'coneSearchEnabled', 'coneSearchOpened', 'coneSearchColumnRa', 'coneSearchColumnDec', 'downloadEnabled', 'downloadOpened', 'downloadCsv', 'downloadAscii', 'downloadVo', 'downloadArchive', 'summaryEnabled', 'summaryOpened', 'serverLinkEnabled', 'serverLinkOpened', 'datatableEnabled', 'datatableOpened', 'datatableSelectableRows', 'survey', 'datasetFamily', 'attributes'];
     }
 
     /**
@@ -305,56 +305,452 @@ class Dataset extends \App\Entity\Dataset implements \Doctrine\ORM\Proxy\Proxy
     /**
      * {@inheritDoc}
      */
-    public function getConfig()
+    public function getPublic()
     {
 
-        $this->__initializer__ && $this->__initializer__->__invoke($this, 'getConfig', []);
+        $this->__initializer__ && $this->__initializer__->__invoke($this, 'getPublic', []);
 
-        return parent::getConfig();
+        return parent::getPublic();
     }
 
     /**
      * {@inheritDoc}
      */
-    public function setConfig($config)
+    public function setPublic($public)
     {
 
-        $this->__initializer__ && $this->__initializer__->__invoke($this, 'setConfig', [$config]);
+        $this->__initializer__ && $this->__initializer__->__invoke($this, 'setPublic', [$public]);
 
-        return parent::setConfig($config);
+        return parent::setPublic($public);
     }
 
     /**
      * {@inheritDoc}
      */
-    public function getPublic()
+    public function getSurvey()
     {
 
-        $this->__initializer__ && $this->__initializer__->__invoke($this, 'getPublic', []);
+        $this->__initializer__ && $this->__initializer__->__invoke($this, 'getSurvey', []);
 
-        return parent::getPublic();
+        return parent::getSurvey();
     }
 
     /**
      * {@inheritDoc}
      */
-    public function setPublic($public)
+    public function getInfoSurveyEnabled()
     {
 
-        $this->__initializer__ && $this->__initializer__->__invoke($this, 'setPublic', [$public]);
+        $this->__initializer__ && $this->__initializer__->__invoke($this, 'getInfoSurveyEnabled', []);
 
-        return parent::setPublic($public);
+        return parent::getInfoSurveyEnabled();
     }
 
     /**
      * {@inheritDoc}
      */
-    public function getSurvey()
+    public function setInfoSurveyEnabled($infoSurveyEnabled)
     {
 
-        $this->__initializer__ && $this->__initializer__->__invoke($this, 'getSurvey', []);
+        $this->__initializer__ && $this->__initializer__->__invoke($this, 'setInfoSurveyEnabled', [$infoSurveyEnabled]);
 
-        return parent::getSurvey();
+        return parent::setInfoSurveyEnabled($infoSurveyEnabled);
+    }
+
+    /**
+     * {@inheritDoc}
+     */
+    public function getInfoSurveyLabel()
+    {
+
+        $this->__initializer__ && $this->__initializer__->__invoke($this, 'getInfoSurveyLabel', []);
+
+        return parent::getInfoSurveyLabel();
+    }
+
+    /**
+     * {@inheritDoc}
+     */
+    public function setInfoSurveyLabel($infoSurveyLabel)
+    {
+
+        $this->__initializer__ && $this->__initializer__->__invoke($this, 'setInfoSurveyLabel', [$infoSurveyLabel]);
+
+        return parent::setInfoSurveyLabel($infoSurveyLabel);
+    }
+
+    /**
+     * {@inheritDoc}
+     */
+    public function getConeSearchEnabled()
+    {
+
+        $this->__initializer__ && $this->__initializer__->__invoke($this, 'getConeSearchEnabled', []);
+
+        return parent::getConeSearchEnabled();
+    }
+
+    /**
+     * {@inheritDoc}
+     */
+    public function setConeSearchEnabled($coneSearchEnabled)
+    {
+
+        $this->__initializer__ && $this->__initializer__->__invoke($this, 'setConeSearchEnabled', [$coneSearchEnabled]);
+
+        return parent::setConeSearchEnabled($coneSearchEnabled);
+    }
+
+    /**
+     * {@inheritDoc}
+     */
+    public function getConeSearchOpened()
+    {
+
+        $this->__initializer__ && $this->__initializer__->__invoke($this, 'getConeSearchOpened', []);
+
+        return parent::getConeSearchOpened();
+    }
+
+    /**
+     * {@inheritDoc}
+     */
+    public function setConeSearchOpened($coneSearchOpened)
+    {
+
+        $this->__initializer__ && $this->__initializer__->__invoke($this, 'setConeSearchOpened', [$coneSearchOpened]);
+
+        return parent::setConeSearchOpened($coneSearchOpened);
+    }
+
+    /**
+     * {@inheritDoc}
+     */
+    public function getConeSearchColumnRa()
+    {
+
+        $this->__initializer__ && $this->__initializer__->__invoke($this, 'getConeSearchColumnRa', []);
+
+        return parent::getConeSearchColumnRa();
+    }
+
+    /**
+     * {@inheritDoc}
+     */
+    public function setConeSearchColumnRa($coneSearchColumnRa)
+    {
+
+        $this->__initializer__ && $this->__initializer__->__invoke($this, 'setConeSearchColumnRa', [$coneSearchColumnRa]);
+
+        return parent::setConeSearchColumnRa($coneSearchColumnRa);
+    }
+
+    /**
+     * {@inheritDoc}
+     */
+    public function getConeSearchColumnDec()
+    {
+
+        $this->__initializer__ && $this->__initializer__->__invoke($this, 'getConeSearchColumnDec', []);
+
+        return parent::getConeSearchColumnDec();
+    }
+
+    /**
+     * {@inheritDoc}
+     */
+    public function setConeSearchColumnDec($coneSearchColumnDec)
+    {
+
+        $this->__initializer__ && $this->__initializer__->__invoke($this, 'setConeSearchColumnDec', [$coneSearchColumnDec]);
+
+        return parent::setConeSearchColumnDec($coneSearchColumnDec);
+    }
+
+    /**
+     * {@inheritDoc}
+     */
+    public function getDownloadEnabled()
+    {
+
+        $this->__initializer__ && $this->__initializer__->__invoke($this, 'getDownloadEnabled', []);
+
+        return parent::getDownloadEnabled();
+    }
+
+    /**
+     * {@inheritDoc}
+     */
+    public function setDownloadEnabled($downloadEnabled)
+    {
+
+        $this->__initializer__ && $this->__initializer__->__invoke($this, 'setDownloadEnabled', [$downloadEnabled]);
+
+        return parent::setDownloadEnabled($downloadEnabled);
+    }
+
+    /**
+     * {@inheritDoc}
+     */
+    public function getDownloadOpened()
+    {
+
+        $this->__initializer__ && $this->__initializer__->__invoke($this, 'getDownloadOpened', []);
+
+        return parent::getDownloadOpened();
+    }
+
+    /**
+     * {@inheritDoc}
+     */
+    public function setDownloadOpened($downloadOpened)
+    {
+
+        $this->__initializer__ && $this->__initializer__->__invoke($this, 'setDownloadOpened', [$downloadOpened]);
+
+        return parent::setDownloadOpened($downloadOpened);
+    }
+
+    /**
+     * {@inheritDoc}
+     */
+    public function getDownloadCsv()
+    {
+
+        $this->__initializer__ && $this->__initializer__->__invoke($this, 'getDownloadCsv', []);
+
+        return parent::getDownloadCsv();
+    }
+
+    /**
+     * {@inheritDoc}
+     */
+    public function setDownloadCsv($downloadCsv)
+    {
+
+        $this->__initializer__ && $this->__initializer__->__invoke($this, 'setDownloadCsv', [$downloadCsv]);
+
+        return parent::setDownloadCsv($downloadCsv);
+    }
+
+    /**
+     * {@inheritDoc}
+     */
+    public function getDownloadAscii()
+    {
+
+        $this->__initializer__ && $this->__initializer__->__invoke($this, 'getDownloadAscii', []);
+
+        return parent::getDownloadAscii();
+    }
+
+    /**
+     * {@inheritDoc}
+     */
+    public function setDownloadAscii($downloadAscii)
+    {
+
+        $this->__initializer__ && $this->__initializer__->__invoke($this, 'setDownloadAscii', [$downloadAscii]);
+
+        return parent::setDownloadAscii($downloadAscii);
+    }
+
+    /**
+     * {@inheritDoc}
+     */
+    public function getDownloadVo()
+    {
+
+        $this->__initializer__ && $this->__initializer__->__invoke($this, 'getDownloadVo', []);
+
+        return parent::getDownloadVo();
+    }
+
+    /**
+     * {@inheritDoc}
+     */
+    public function setDownloadVo($downloadVo)
+    {
+
+        $this->__initializer__ && $this->__initializer__->__invoke($this, 'setDownloadVo', [$downloadVo]);
+
+        return parent::setDownloadVo($downloadVo);
+    }
+
+    /**
+     * {@inheritDoc}
+     */
+    public function getDownloadArchive()
+    {
+
+        $this->__initializer__ && $this->__initializer__->__invoke($this, 'getDownloadArchive', []);
+
+        return parent::getDownloadArchive();
+    }
+
+    /**
+     * {@inheritDoc}
+     */
+    public function setDownloadArchive($downloadArchive)
+    {
+
+        $this->__initializer__ && $this->__initializer__->__invoke($this, 'setDownloadArchive', [$downloadArchive]);
+
+        return parent::setDownloadArchive($downloadArchive);
+    }
+
+    /**
+     * {@inheritDoc}
+     */
+    public function getSummaryEnabled()
+    {
+
+        $this->__initializer__ && $this->__initializer__->__invoke($this, 'getSummaryEnabled', []);
+
+        return parent::getSummaryEnabled();
+    }
+
+    /**
+     * {@inheritDoc}
+     */
+    public function setSummaryEnabled($summaryEnabled)
+    {
+
+        $this->__initializer__ && $this->__initializer__->__invoke($this, 'setSummaryEnabled', [$summaryEnabled]);
+
+        return parent::setSummaryEnabled($summaryEnabled);
+    }
+
+    /**
+     * {@inheritDoc}
+     */
+    public function getSummaryOpened()
+    {
+
+        $this->__initializer__ && $this->__initializer__->__invoke($this, 'getSummaryOpened', []);
+
+        return parent::getSummaryOpened();
+    }
+
+    /**
+     * {@inheritDoc}
+     */
+    public function setSummaryOpened($summaryOpened)
+    {
+
+        $this->__initializer__ && $this->__initializer__->__invoke($this, 'setSummaryOpened', [$summaryOpened]);
+
+        return parent::setSummaryOpened($summaryOpened);
+    }
+
+    /**
+     * {@inheritDoc}
+     */
+    public function getServerLinkEnabled()
+    {
+
+        $this->__initializer__ && $this->__initializer__->__invoke($this, 'getServerLinkEnabled', []);
+
+        return parent::getServerLinkEnabled();
+    }
+
+    /**
+     * {@inheritDoc}
+     */
+    public function setServerLinkEnabled($serverLinkEnabled)
+    {
+
+        $this->__initializer__ && $this->__initializer__->__invoke($this, 'setServerLinkEnabled', [$serverLinkEnabled]);
+
+        return parent::setServerLinkEnabled($serverLinkEnabled);
+    }
+
+    /**
+     * {@inheritDoc}
+     */
+    public function getServerLinkOpened()
+    {
+
+        $this->__initializer__ && $this->__initializer__->__invoke($this, 'getServerLinkOpened', []);
+
+        return parent::getServerLinkOpened();
+    }
+
+    /**
+     * {@inheritDoc}
+     */
+    public function setServerLinkOpened($serverLinkOpened)
+    {
+
+        $this->__initializer__ && $this->__initializer__->__invoke($this, 'setServerLinkOpened', [$serverLinkOpened]);
+
+        return parent::setServerLinkOpened($serverLinkOpened);
+    }
+
+    /**
+     * {@inheritDoc}
+     */
+    public function getDatatableEnabled()
+    {
+
+        $this->__initializer__ && $this->__initializer__->__invoke($this, 'getDatatableEnabled', []);
+
+        return parent::getDatatableEnabled();
+    }
+
+    /**
+     * {@inheritDoc}
+     */
+    public function setDatatableEnabled($datatableEnabled)
+    {
+
+        $this->__initializer__ && $this->__initializer__->__invoke($this, 'setDatatableEnabled', [$datatableEnabled]);
+
+        return parent::setDatatableEnabled($datatableEnabled);
+    }
+
+    /**
+     * {@inheritDoc}
+     */
+    public function getDatatableOpened()
+    {
+
+        $this->__initializer__ && $this->__initializer__->__invoke($this, 'getDatatableOpened', []);
+
+        return parent::getDatatableOpened();
+    }
+
+    /**
+     * {@inheritDoc}
+     */
+    public function setDatatableOpened($datatableOpened)
+    {
+
+        $this->__initializer__ && $this->__initializer__->__invoke($this, 'setDatatableOpened', [$datatableOpened]);
+
+        return parent::setDatatableOpened($datatableOpened);
+    }
+
+    /**
+     * {@inheritDoc}
+     */
+    public function getDatatableSelectableRows()
+    {
+
+        $this->__initializer__ && $this->__initializer__->__invoke($this, 'getDatatableSelectableRows', []);
+
+        return parent::getDatatableSelectableRows();
+    }
+
+    /**
+     * {@inheritDoc}
+     */
+    public function setDatatableSelectableRows($datatableSelectableRows)
+    {
+
+        $this->__initializer__ && $this->__initializer__->__invoke($this, 'setDatatableSelectableRows', [$datatableSelectableRows]);
+
+        return parent::setDatatableSelectableRows($datatableSelectableRows);
     }
 
     /**
diff --git a/server/doctrine-proxy/__CG__AppEntityInstance.php b/server/doctrine-proxy/__CG__AppEntityInstance.php
index 1a1fb771..d5f1d1d1 100644
--- a/server/doctrine-proxy/__CG__AppEntityInstance.php
+++ b/server/doctrine-proxy/__CG__AppEntityInstance.php
@@ -67,10 +67,10 @@ class Instance extends \App\Entity\Instance implements \Doctrine\ORM\Proxy\Proxy
     public function __sleep()
     {
         if ($this->__isInitialized__) {
-            return ['__isInitialized__', 'name', 'label', 'description', 'display', 'dataPath', 'portalLogo', 'designColor', 'designBackgroundColor', 'designLogo', 'designFavicon', 'homeComponent', 'homeComponentConfig', 'searchByCriteriaAllowed', 'searchByCriteriaLabel', 'searchMultipleAllowed', 'searchMultipleLabel', 'searchMultipleAllDatasetsSelected', 'documentationAllowed', 'documentationLabel', 'datasetFamilies'];
+            return ['__isInitialized__', 'name', 'label', 'description', 'display', 'dataPath', 'public', 'portalLogo', 'designColor', 'designBackgroundColor', 'designLogo', 'designFavicon', 'homeComponent', 'homeComponentConfig', 'sampEnabled', 'searchByCriteriaAllowed', 'searchByCriteriaLabel', 'searchMultipleAllowed', 'searchMultipleLabel', 'searchMultipleAllDatasetsSelected', 'documentationAllowed', 'documentationLabel', 'datasetFamilies'];
         }
 
-        return ['__isInitialized__', 'name', 'label', 'description', 'display', 'dataPath', 'portalLogo', 'designColor', 'designBackgroundColor', 'designLogo', 'designFavicon', 'homeComponent', 'homeComponentConfig', 'searchByCriteriaAllowed', 'searchByCriteriaLabel', 'searchMultipleAllowed', 'searchMultipleLabel', 'searchMultipleAllDatasetsSelected', 'documentationAllowed', 'documentationLabel', 'datasetFamilies'];
+        return ['__isInitialized__', 'name', 'label', 'description', 'display', 'dataPath', 'public', 'portalLogo', 'designColor', 'designBackgroundColor', 'designLogo', 'designFavicon', 'homeComponent', 'homeComponentConfig', 'sampEnabled', 'searchByCriteriaAllowed', 'searchByCriteriaLabel', 'searchMultipleAllowed', 'searchMultipleLabel', 'searchMultipleAllDatasetsSelected', 'documentationAllowed', 'documentationLabel', 'datasetFamilies'];
     }
 
     /**
@@ -280,6 +280,28 @@ class Instance extends \App\Entity\Instance implements \Doctrine\ORM\Proxy\Proxy
         return parent::setDataPath($dataPath);
     }
 
+    /**
+     * {@inheritDoc}
+     */
+    public function getPublic()
+    {
+
+        $this->__initializer__ && $this->__initializer__->__invoke($this, 'getPublic', []);
+
+        return parent::getPublic();
+    }
+
+    /**
+     * {@inheritDoc}
+     */
+    public function setPublic($public)
+    {
+
+        $this->__initializer__ && $this->__initializer__->__invoke($this, 'setPublic', [$public]);
+
+        return parent::setPublic($public);
+    }
+
     /**
      * {@inheritDoc}
      */
@@ -434,6 +456,28 @@ class Instance extends \App\Entity\Instance implements \Doctrine\ORM\Proxy\Proxy
         return parent::setHomeComponentConfig($homeComponentConfig);
     }
 
+    /**
+     * {@inheritDoc}
+     */
+    public function getSampEnabled()
+    {
+
+        $this->__initializer__ && $this->__initializer__->__invoke($this, 'getSampEnabled', []);
+
+        return parent::getSampEnabled();
+    }
+
+    /**
+     * {@inheritDoc}
+     */
+    public function setSampEnabled($sampEnabled)
+    {
+
+        $this->__initializer__ && $this->__initializer__->__invoke($this, 'setSampEnabled', [$sampEnabled]);
+
+        return parent::setSampEnabled($sampEnabled);
+    }
+
     /**
      * {@inheritDoc}
      */
diff --git a/server/doctrine-proxy/__CG__AppEntityInstanceGroup.php b/server/doctrine-proxy/__CG__AppEntityInstanceGroup.php
new file mode 100644
index 00000000..739fe6fd
--- /dev/null
+++ b/server/doctrine-proxy/__CG__AppEntityInstanceGroup.php
@@ -0,0 +1,250 @@
+<?php
+
+namespace DoctrineProxies\__CG__\App\Entity;
+
+
+/**
+ * DO NOT EDIT THIS FILE - IT WAS CREATED BY DOCTRINE'S PROXY GENERATOR
+ */
+class InstanceGroup extends \App\Entity\InstanceGroup implements \Doctrine\ORM\Proxy\Proxy
+{
+    /**
+     * @var \Closure the callback responsible for loading properties in the proxy object. This callback is called with
+     *      three parameters, being respectively the proxy object to be initialized, the method that triggered the
+     *      initialization process and an array of ordered parameters that were passed to that method.
+     *
+     * @see \Doctrine\Common\Proxy\Proxy::__setInitializer
+     */
+    public $__initializer__;
+
+    /**
+     * @var \Closure the callback responsible of loading properties that need to be copied in the cloned object
+     *
+     * @see \Doctrine\Common\Proxy\Proxy::__setCloner
+     */
+    public $__cloner__;
+
+    /**
+     * @var boolean flag indicating if this object was already initialized
+     *
+     * @see \Doctrine\Persistence\Proxy::__isInitialized
+     */
+    public $__isInitialized__ = false;
+
+    /**
+     * @var array<string, null> properties to be lazy loaded, indexed by property name
+     */
+    public static $lazyPropertiesNames = array (
+);
+
+    /**
+     * @var array<string, mixed> default values of properties to be lazy loaded, with keys being the property names
+     *
+     * @see \Doctrine\Common\Proxy\Proxy::__getLazyProperties
+     */
+    public static $lazyPropertiesDefaults = array (
+);
+
+
+
+    public function __construct(?\Closure $initializer = null, ?\Closure $cloner = null)
+    {
+
+        $this->__initializer__ = $initializer;
+        $this->__cloner__      = $cloner;
+    }
+
+
+
+
+
+
+
+    /**
+     * 
+     * @return array
+     */
+    public function __sleep()
+    {
+        if ($this->__isInitialized__) {
+            return ['__isInitialized__', 'id', 'role', 'instances'];
+        }
+
+        return ['__isInitialized__', 'id', 'role', 'instances'];
+    }
+
+    /**
+     * 
+     */
+    public function __wakeup()
+    {
+        if ( ! $this->__isInitialized__) {
+            $this->__initializer__ = function (InstanceGroup $proxy) {
+                $proxy->__setInitializer(null);
+                $proxy->__setCloner(null);
+
+                $existingProperties = get_object_vars($proxy);
+
+                foreach ($proxy::$lazyPropertiesDefaults as $property => $defaultValue) {
+                    if ( ! array_key_exists($property, $existingProperties)) {
+                        $proxy->$property = $defaultValue;
+                    }
+                }
+            };
+
+        }
+    }
+
+    /**
+     * 
+     */
+    public function __clone()
+    {
+        $this->__cloner__ && $this->__cloner__->__invoke($this, '__clone', []);
+    }
+
+    /**
+     * Forces initialization of the proxy
+     */
+    public function __load()
+    {
+        $this->__initializer__ && $this->__initializer__->__invoke($this, '__load', []);
+    }
+
+    /**
+     * {@inheritDoc}
+     * @internal generated method: use only when explicitly handling proxy specific loading logic
+     */
+    public function __isInitialized()
+    {
+        return $this->__isInitialized__;
+    }
+
+    /**
+     * {@inheritDoc}
+     * @internal generated method: use only when explicitly handling proxy specific loading logic
+     */
+    public function __setInitialized($initialized)
+    {
+        $this->__isInitialized__ = $initialized;
+    }
+
+    /**
+     * {@inheritDoc}
+     * @internal generated method: use only when explicitly handling proxy specific loading logic
+     */
+    public function __setInitializer(\Closure $initializer = null)
+    {
+        $this->__initializer__ = $initializer;
+    }
+
+    /**
+     * {@inheritDoc}
+     * @internal generated method: use only when explicitly handling proxy specific loading logic
+     */
+    public function __getInitializer()
+    {
+        return $this->__initializer__;
+    }
+
+    /**
+     * {@inheritDoc}
+     * @internal generated method: use only when explicitly handling proxy specific loading logic
+     */
+    public function __setCloner(\Closure $cloner = null)
+    {
+        $this->__cloner__ = $cloner;
+    }
+
+    /**
+     * {@inheritDoc}
+     * @internal generated method: use only when explicitly handling proxy specific cloning logic
+     */
+    public function __getCloner()
+    {
+        return $this->__cloner__;
+    }
+
+    /**
+     * {@inheritDoc}
+     * @internal generated method: use only when explicitly handling proxy specific loading logic
+     * @deprecated no longer in use - generated code now relies on internal components rather than generated public API
+     * @static
+     */
+    public function __getLazyProperties()
+    {
+        return self::$lazyPropertiesDefaults;
+    }
+
+    
+    /**
+     * {@inheritDoc}
+     */
+    public function getId()
+    {
+        if ($this->__isInitialized__ === false) {
+            return (int)  parent::getId();
+        }
+
+
+        $this->__initializer__ && $this->__initializer__->__invoke($this, 'getId', []);
+
+        return parent::getId();
+    }
+
+    /**
+     * {@inheritDoc}
+     */
+    public function getRole()
+    {
+
+        $this->__initializer__ && $this->__initializer__->__invoke($this, 'getRole', []);
+
+        return parent::getRole();
+    }
+
+    /**
+     * {@inheritDoc}
+     */
+    public function setRole($role)
+    {
+
+        $this->__initializer__ && $this->__initializer__->__invoke($this, 'setRole', [$role]);
+
+        return parent::setRole($role);
+    }
+
+    /**
+     * {@inheritDoc}
+     */
+    public function getInstances()
+    {
+
+        $this->__initializer__ && $this->__initializer__->__invoke($this, 'getInstances', []);
+
+        return parent::getInstances();
+    }
+
+    /**
+     * {@inheritDoc}
+     */
+    public function setInstances($instances)
+    {
+
+        $this->__initializer__ && $this->__initializer__->__invoke($this, 'setInstances', [$instances]);
+
+        return parent::setInstances($instances);
+    }
+
+    /**
+     * {@inheritDoc}
+     */
+    public function jsonSerialize(): array
+    {
+
+        $this->__initializer__ && $this->__initializer__->__invoke($this, 'jsonSerialize', []);
+
+        return parent::jsonSerialize();
+    }
+
+}
diff --git a/server/src/Action/InstanceAction.php b/server/src/Action/InstanceAction.php
index 750bd1b7..7f2b5789 100644
--- a/server/src/Action/InstanceAction.php
+++ b/server/src/Action/InstanceAction.php
@@ -99,6 +99,7 @@ final class InstanceAction extends AbstractAction
         $instance->setDescription($parsedBody['description']);
         $instance->setDisplay($parsedBody['display']);
         $instance->setDataPath($parsedBody['data_path']);
+        $instance->setPublic($parsedBody['public']);
         $instance->setPortalLogo($parsedBody['portal_logo']);
         $instance->setDesignColor($parsedBody['design_color']);
         $instance->setDesignBackgroundColor($parsedBody['design_background_color']);
diff --git a/server/src/Action/InstanceGroupAction.php b/server/src/Action/InstanceGroupAction.php
new file mode 100644
index 00000000..ae7d2688
--- /dev/null
+++ b/server/src/Action/InstanceGroupAction.php
@@ -0,0 +1,125 @@
+<?php
+
+/*
+ * This file is part of Anis Server.
+ *
+ * (c) Laboratoire d'Astrophysique de Marseille / CNRS
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+declare(strict_types=1);
+
+namespace App\Action;
+
+use Psr\Http\Message\ServerRequestInterface;
+use Psr\Http\Message\ResponseInterface;
+use Slim\Exception\HttpBadRequestException;
+use Slim\Exception\HttpNotFoundException;
+use App\Entity\InstanceGroup;
+use App\Entity\Instance;
+
+/**
+ * @author François Agneray <francois.agneray@lam.fr>
+ * @package App\Action
+ */
+final class InstanceGroupAction extends AbstractAction
+{
+    /**
+     * `GET` Returns the InstanceGroup found
+     * `PUT` Full update the InstanceGroup and returns the new version
+     * `DELETE` Delete the InstanceGroup found and return a confirmation message
+     *
+     * @param  ServerRequestInterface $request  PSR-7 This object represents the HTTP request
+     * @param  ResponseInterface      $response PSR-7 This object represents the HTTP response
+     * @param  string[]               $args     This table contains information transmitted in the URL (see routes.php)
+     *
+     * @return ResponseInterface
+     */
+    public function __invoke(
+        ServerRequestInterface $request,
+        ResponseInterface $response,
+        array $args
+    ): ResponseInterface {
+        if ($request->getMethod() === OPTIONS) {
+            return $response->withHeader('Access-Control-Allow-Methods', 'GET, PUT, DELETE, OPTIONS');
+        }
+
+        // Search the correct instance-group with primary key
+        $instanceGroup = $this->em->find('App\Entity\InstanceGroup', $args['id']);
+
+        // If group is not found 404
+        if (is_null($instanceGroup)) {
+            throw new HttpNotFoundException(
+                $request,
+                'Instance-group with id ' . $args['id'] . ' is not found'
+            );
+        }
+
+        if ($request->getMethod() === GET) {
+            $payload = json_encode($instanceGroup);
+        }
+
+        if ($request->getMethod() === PUT) {
+            $parsedBody = $request->getParsedBody();
+
+            // If mandatories empty fields 400
+            foreach (array('role', 'instances') as $a) {
+                if ($this->isEmptyField($a, $parsedBody)) {
+                    throw new HttpBadRequestException(
+                        $request,
+                        'Param ' . $a . ' needed to edit the instance-group'
+                    );
+                }
+            }
+
+            $this->editInstanceGroup($instanceGroup, $parsedBody);
+            $payload = json_encode($instanceGroup);
+        }
+
+        if ($request->getMethod() === DELETE) {
+            $id = $instanceGroup->getId();
+            $this->em->remove($instanceGroup);
+            $this->em->flush();
+            $payload = json_encode(array('message' => 'Instance-group with id ' . $id . ' is removed!'));
+        }
+
+        $response->getBody()->write($payload);
+        return $response;
+    }
+
+    /**
+     * Update instance-group object with setters
+     *
+     * @param InstanceGroup $instanceGroup The instance-group to update
+     * @param array         $parsedBody    Contains the new values ​​of the instance-group sent by the user
+     */
+    private function editInstanceGroup(InstanceGroup $instanceGroup, array $parsedBody): void
+    {
+        $instanceGroup->setRole($parsedBody['role']);
+        $instanceGroup->setInstances($this->getInstances($parsedBody['instances']));
+        $this->em->flush();
+    }
+
+    /**
+     * Retrieves list of instances by list of instances names
+     *
+     * @param string[] $listOfInstancesNames List of instances names
+     *
+     * @return Instance[] List of instances found
+     */
+    private function getInstances(array $listOfInstancesNames): array
+    {
+        if (count($listOfInstancesNames) < 1) {
+            return array();
+        }
+
+        $in = implode(',', array_map(function ($d) {
+            return "'" . $d . "'";
+        }, $listOfInstancesNames));
+
+        $dql = 'SELECT i FROM App\Entity\Instance i WHERE i.name IN (' . $in . ')';
+        $query = $this->em->createQuery($dql);
+        return $query->getResult();
+    }
+}
diff --git a/server/src/Action/InstanceGroupListAction.php b/server/src/Action/InstanceGroupListAction.php
new file mode 100644
index 00000000..ae7760ff
--- /dev/null
+++ b/server/src/Action/InstanceGroupListAction.php
@@ -0,0 +1,113 @@
+<?php
+
+/*
+ * This file is part of Anis Server.
+ *
+ * (c) Laboratoire d'Astrophysique de Marseille / CNRS
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+declare(strict_types=1);
+
+namespace App\Action;
+
+use Psr\Http\Message\ServerRequestInterface;
+use Psr\Http\Message\ResponseInterface;
+use Slim\Exception\HttpBadRequestException;
+use App\Entity\InstanceGroup;
+use App\Entity\Instance;
+
+/**
+ * @author François Agneray <francois.agneray@lam.fr>
+ * @package App\Action
+ */
+final class InstanceGroupListAction extends AbstractAction
+{
+    /**
+     * `GET`  Returns a list of all instance-groups listed in the metamodel database
+     * `POST` Add a new instance-group
+     *
+     * @param  ServerRequestInterface $request  PSR-7 This object represents the HTTP request
+     * @param  ResponseInterface      $response PSR-7 This object represents the HTTP response
+     * @param  string[]               $args     This table contains information transmitted in the URL (see routes.php)
+     *
+     * @return ResponseInterface
+     */
+    public function __invoke(
+        ServerRequestInterface $request,
+        ResponseInterface $response,
+        array $args
+    ): ResponseInterface {
+        if ($request->getMethod() === OPTIONS) {
+            return $response->withHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS');
+        }
+
+        if ($request->getMethod() === GET) {
+            $instanceGroups = $this->em->getRepository('App\Entity\InstanceGroup')->findAll();
+            $payload = json_encode($instanceGroups);
+        }
+
+        if ($request->getMethod() === POST) {
+            $parsedBody = $request->getParsedBody();
+
+            // To work this action needs instance-group information
+            foreach (array('role', 'instances') as $a) {
+                if ($this->isEmptyField($a, $parsedBody)) {
+                    throw new HttpBadRequestException(
+                        $request,
+                        'Param ' . $a . ' needed to add a new instance-group'
+                    );
+                }
+            }
+
+            $instanceGroup = $this->postInstanceGroup($parsedBody);
+            $payload = json_encode($instanceGroup);
+            $response = $response->withStatus(201);
+        }
+
+        $response->getBody()->write($payload);
+        return $response;
+    }
+
+    /**
+     * Add a new instance-group into the metamodel
+     *
+     * @param array    $parsedBody Contains the values ​​of the new instance-group sent by the user
+     *
+     * @return InstanceGroup The newly created instance-group
+     */
+    private function postInstanceGroup(array $parsedBody): InstanceGroup
+    {
+        $instanceGroup = new InstanceGroup();
+        $instanceGroup->setRole($parsedBody['role']);
+        $instanceGroup->setInstances($this->getInstances($parsedBody['instances']));
+
+        $this->em->persist($instanceGroup);
+        $this->em->flush();
+
+        return $instanceGroup;
+    }
+
+    /**
+     * Retrieves list of instances by list of instances names
+     *
+     * @param string[] $listOfInstancesNames List of instances names
+     *
+     * @return Instance[] List of instances found
+     */
+    private function getInstances(array $listOfInstancesNames): array
+    {
+        if (count($listOfInstancesNames) < 1) {
+            return array();
+        }
+
+        $in = implode(',', array_map(function ($d) {
+            return "'" . $d . "'";
+        }, $listOfInstancesNames));
+
+        $dql = 'SELECT i FROM App\Entity\Instance i WHERE i.name IN (' . $in . ')';
+        $query = $this->em->createQuery($dql);
+        return $query->getResult();
+    }
+}
diff --git a/server/src/Action/InstanceListAction.php b/server/src/Action/InstanceListAction.php
index 628961d4..9aa4c2a9 100644
--- a/server/src/Action/InstanceListAction.php
+++ b/server/src/Action/InstanceListAction.php
@@ -82,6 +82,7 @@ final class InstanceListAction extends AbstractAction
         $instance->setDescription($parsedBody['description']);
         $instance->setDisplay($parsedBody['display']);
         $instance->setDataPath($parsedBody['data_path']);
+        $instance->setPublic($parsedBody['public']);
         $instance->setPortalLogo($parsedBody['portal_logo']);
         $instance->setDesignColor($parsedBody['design_color']);
         $instance->setDesignBackgroundColor($parsedBody['design_background_color']);
diff --git a/server/src/Entity/Instance.php b/server/src/Entity/Instance.php
index 213386f3..044e3261 100644
--- a/server/src/Entity/Instance.php
+++ b/server/src/Entity/Instance.php
@@ -24,7 +24,7 @@ use Doctrine\Common\Collections\ArrayCollection;
 class Instance implements \JsonSerializable
 {
     /**
-     * @var int
+     * @var string
      *
      * @Id
      * @Column(type="string", nullable=false)
@@ -59,6 +59,13 @@ class Instance implements \JsonSerializable
      */
     protected $dataPath;
 
+    /**
+     * @var bool
+     *
+     * @Column(type="boolean", nullable=false)
+     */
+    protected $public;
+
     /**
      * @var string
      *
@@ -223,6 +230,16 @@ class Instance implements \JsonSerializable
         $this->dataPath = $dataPath;
     }
 
+    public function getPublic()
+    {
+        return $this->public;
+    }
+
+    public function setPublic($public)
+    {
+        $this->public = $public;
+    }
+
     public function getPortalLogo()
     {
         return $this->portalLogo;
@@ -395,6 +412,7 @@ class Instance implements \JsonSerializable
             'description' => $this->getDescription(),
             'display' => $this->getDisplay(),
             'data_path' => $this->getDataPath(),
+            'public' => $this->getPublic(),
             'portal_logo' => $this->getPortalLogo(),
             'design_color' => $this->getDesignColor(),
             'design_background_color' => $this->getDesignBackgroundColor(),
diff --git a/server/src/Entity/InstanceGroup.php b/server/src/Entity/InstanceGroup.php
new file mode 100644
index 00000000..35423f29
--- /dev/null
+++ b/server/src/Entity/InstanceGroup.php
@@ -0,0 +1,92 @@
+<?php
+
+/*
+ * This file is part of Anis Server.
+ *
+ * (c) Laboratoire d'Astrophysique de Marseille / CNRS
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+declare(strict_types=1);
+
+namespace App\Entity;
+
+/**
+ * @author François Agneray <francois.agneray@lam.fr>
+ * @package App\Entity
+ *
+ * @Entity
+ * @Table(name="instance_group")
+ */
+class InstanceGroup implements \JsonSerializable
+{
+    /**
+     * @var int
+     *
+     * @Id
+     * @Column(type="integer", nullable=false)
+     * @GeneratedValue
+     */
+    protected $id;
+
+    /**
+     * @var string
+     *
+     * @Column(type="string", nullable=false)
+     */
+    protected $role;
+
+    /**
+     * @var Instance[]
+     *
+     * Many Groups have Many Instances privileges.
+     *
+     * @ManyToMany(targetEntity="Instance")
+     * @JoinTable(
+     *     name="instance_groups_datasets",
+     *     joinColumns={@JoinColumn(name="instance_group_id", referencedColumnName="id", onDelete="CASCADE")},
+     *     inverseJoinColumns={@JoinColumn(name="instance_name", referencedColumnName="name", onDelete="CASCADE")}
+     * )
+     */
+    protected $instances;
+
+    public function getId()
+    {
+        return $this->id;
+    }
+
+    public function getRole()
+    {
+        return $this->role;
+    }
+
+    public function setRole($role)
+    {
+        $this->role = $role;
+    }
+
+    public function getInstances()
+    {
+        return $this->instances;
+    }
+
+    public function setInstances($instances)
+    {
+        $this->instances = $instances;
+    }
+
+    public function jsonSerialize(): array
+    {
+        $instanceNames = array();
+        foreach ($this->getInstances() as $instance) {
+            $instanceNames[] = $instance->getName();
+        }
+
+        return [
+            'id' => $this->getId(),
+            'role' => $this->getRole(),
+            'instances' => $instanceNames
+        ];
+    }
+}
-- 
GitLab