You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@metron.apache.org by sa...@apache.org on 2019/07/31 08:57:43 UTC

[metron] branch feature/METRON-1856-parser-aggregation updated: METRON-2134 Add NgRx reducers to perform parser and group changes in the store (ruffle1986 via sardell) closes apache/metron#1425

This is an automated email from the ASF dual-hosted git repository.

sardell pushed a commit to branch feature/METRON-1856-parser-aggregation
in repository https://gitbox.apache.org/repos/asf/metron.git


The following commit(s) were added to refs/heads/feature/METRON-1856-parser-aggregation by this push:
     new 687d693  METRON-2134 Add NgRx reducers to perform parser and group changes in the store (ruffle1986 via sardell) closes apache/metron#1425
687d693 is described below

commit 687d69349fa0543999f566b8fddb3b7410d709a2
Author: ruffle1986 <ft...@gmail.com>
AuthorDate: Wed Jul 31 10:57:19 2019 +0200

    METRON-2134 Add NgRx reducers to perform parser and group changes in the store (ruffle1986 via sardell) closes apache/metron#1425
---
 .../app/sensors/models/parser-meta-info.model.ts   |  34 +
 .../src/app/sensors/reducers/index.ts              |  39 ++
 .../app/sensors/reducers/sensors.reducers.spec.ts  | 720 +++++++++++++++++++++
 .../src/app/sensors/reducers/sensors.reducers.ts   | 638 ++++++++++++++++++
 4 files changed, 1431 insertions(+)

diff --git a/metron-interface/metron-config/src/app/sensors/models/parser-meta-info.model.ts b/metron-interface/metron-config/src/app/sensors/models/parser-meta-info.model.ts
new file mode 100644
index 0000000..4588789
--- /dev/null
+++ b/metron-interface/metron-config/src/app/sensors/models/parser-meta-info.model.ts
@@ -0,0 +1,34 @@
+/**
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+import { TopologyStatus } from '../../model/topology-status';
+import { ParserModel } from './parser.model';
+
+export interface ParserMetaInfoModel {
+  config: ParserModel;
+  status?: TopologyStatus;
+  isGroup?: boolean;
+  isHighlighted?: boolean;
+  isDraggedOver?: boolean;
+  isPhantom?: boolean;
+  isDirty?: boolean;
+  isDeleted?: boolean;
+  startStopInProgress?: boolean;
+  modifiedByDate?: string;
+  modifiedBy?: string;
+  isRunning?: boolean;
+}
diff --git a/metron-interface/metron-config/src/app/sensors/reducers/index.ts b/metron-interface/metron-config/src/app/sensors/reducers/index.ts
new file mode 100644
index 0000000..fcf50d6
--- /dev/null
+++ b/metron-interface/metron-config/src/app/sensors/reducers/index.ts
@@ -0,0 +1,39 @@
+/**
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+import { ActionReducerMap } from '@ngrx/store';
+import * as fromSensors from './sensors.reducers';
+
+export * from './sensors.reducers';
+
+export interface State {
+  sensors: SensorState
+}
+
+export interface SensorState {
+  parsers: fromSensors.ParserState;
+  groups: fromSensors.GroupState;
+  statuses: fromSensors.StatusState;
+  layout: fromSensors.LayoutState
+}
+
+export const reducers: ActionReducerMap<SensorState> = {
+  parsers: fromSensors.parserConfigsReducer,
+  groups: fromSensors.groupConfigsReducer,
+  statuses: fromSensors.parserStatusReducer,
+  layout: fromSensors.layoutReducer
+}
diff --git a/metron-interface/metron-config/src/app/sensors/reducers/sensors.reducers.spec.ts b/metron-interface/metron-config/src/app/sensors/reducers/sensors.reducers.spec.ts
new file mode 100644
index 0000000..14eb9ab
--- /dev/null
+++ b/metron-interface/metron-config/src/app/sensors/reducers/sensors.reducers.spec.ts
@@ -0,0 +1,720 @@
+/**
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+import * as fromReducers from './';
+import * as fromActionts from '../actions';
+import { ParserConfigModel } from '../models/parser-config.model';
+import { ParserGroupModel } from '../models/parser-group.model';
+import { TopologyStatus } from '../../model/topology-status';
+
+describe('sensors: parsers configs reducer', () => {
+
+  it('should return with the initial state by default', () => {
+    expect(
+      fromReducers.parserConfigsReducer(undefined, { type: '' })
+    ).toBe(fromReducers.initialParserState);
+  });
+
+  it('should return with the previous state', () => {
+    const previousState = { items: [] };
+    expect(
+      fromReducers.parserConfigsReducer(previousState, { type: '' })
+    ).toBe(previousState);
+  });
+
+  it('should set items on LoadSuccess', () => {
+    const parsers = [];
+    const previousState = {
+      items: []
+    };
+    const action = new fromActionts.LoadSuccess({
+      parsers
+    });
+
+    expect(
+      fromReducers.parserConfigsReducer(previousState, action).items
+    ).toBe(parsers);
+  });
+
+  it('should aggregate parsers on AggregateParsers', () => {
+    const groupName = 'Foo group';
+    const parserIds = [
+      'Parser Config ID 02',
+    ]
+    const previousState: fromReducers.ParserState = {
+      items: [{
+        config: new ParserConfigModel('Parser Config ID 01', { sensorTopic: 'Kafka/Sensor Topic ID 1'})
+      }, {
+        config: new ParserConfigModel('Parser Config ID 02', { sensorTopic: 'Kafka/Sensor Topic ID 2'})
+      }]
+    };
+    const action = new fromActionts.AggregateParsers({
+      groupName,
+      parserIds,
+    });
+
+    const newState = fromReducers.parserConfigsReducer(previousState, action);
+    expect(newState.items[0]).toBe(previousState.items[0]);
+    expect(newState.items[1]).not.toBe(previousState.items[1]);
+    expect(newState.items[1].isDirty).toBe(true);
+    expect(newState.items[1].config.getName()).toBe('Parser Config ID 02');
+    expect(newState.items[1].config.group).toEqual(groupName);
+  });
+
+  it('should set group on AddToGroup', () => {
+    const groupName = 'Foo group';
+    const parserIds = [
+      'Parser Config ID 02',
+    ]
+    const previousState: fromReducers.ParserState = {
+      items: [{
+        config: new ParserConfigModel('Parser Config ID 01', { sensorTopic: 'Kafka/Sensor Topic ID 1'})
+      }, {
+        config: new ParserConfigModel('Parser Config ID 02', { sensorTopic: 'Kafka/Sensor Topic ID 2'})
+      }]
+    };
+    const action = new fromActionts.AddToGroup({
+      groupName,
+      parserIds,
+    });
+
+    const newState = fromReducers.parserConfigsReducer(previousState, action);
+    expect(newState.items[0]).toBe(previousState.items[0]);
+    expect(newState.items[1]).not.toBe(previousState.items[1]);
+    expect(newState.items[1].isDirty).toBe(true);
+    expect(newState.items[1].config.getName()).toBe('Parser Config ID 02');
+    expect(newState.items[1].config.group).toEqual(groupName);
+  });
+
+  it('should mark items as deleted on MarkAsDeleted', () => {
+    const parserIds = ['Parser Config ID 02'];
+    const previousState: fromReducers.ParserState = {
+      items: [{
+        config: new ParserConfigModel('Parser Config ID 01', { sensorTopic: 'Kafka/Sensor Topic ID 1'})
+      }, {
+        config: new ParserConfigModel('Parser Config ID 02', { sensorTopic: 'Kafka/Sensor Topic ID 2'})
+      }]
+    };
+    const action = new fromActionts.MarkAsDeleted({ parserIds });
+    const newState = fromReducers.parserConfigsReducer(previousState, action);
+    expect(newState.items[0]).toBe(previousState.items[0]);
+    expect(newState.items[1]).not.toBe(previousState.items[1]);
+    expect(newState.items[1].isDeleted).toBe(true);
+  });
+
+  it('should remove group property of items which belong to a group marked as deleted on MarkAsDeleted', () => {
+    const groupName = 'Foo Group';
+    const parserIds = [groupName];
+    const previousState: fromReducers.ParserState = {
+      items: [{
+        config: new ParserConfigModel('Parser Config ID 01', { sensorTopic: 'Kafka/Sensor Topic ID 1'})
+      }, {
+        config: new ParserConfigModel('Parser Config ID 02', { sensorTopic: 'Kafka/Sensor Topic ID 2', group: groupName})
+      }]
+    };
+    const action = new fromActionts.MarkAsDeleted({ parserIds });
+    const newState = fromReducers.parserConfigsReducer(previousState, action);
+    expect(newState.items[0]).toBe(previousState.items[0]);
+    expect(newState.items[1]).not.toBe(previousState.items[1]);
+    expect(newState.items[1].isDeleted).toBeFalsy();
+    expect(newState.items[1].config.group).toBe('');
+  });
+});
+
+describe('sensors: group configs reducer', () => {
+
+  it('should return with the initial state by default', () => {
+    expect(
+      fromReducers.groupConfigsReducer(undefined, { type: '' })
+    ).toBe(fromReducers.initialGroupState);
+  });
+
+  it('should return with the previous state', () => {
+    const previousState = { items: [] };
+    expect(
+      fromReducers.groupConfigsReducer(previousState, { type: '' })
+    ).toBe(previousState);
+  });
+
+  it('should set items on LoadSuccess', () => {
+    const groups = [];
+    const previousState = {
+      items: []
+    };
+    const action = new fromActionts.LoadSuccess({
+      groups
+    });
+
+    expect(
+      fromReducers.groupConfigsReducer(previousState, action).items
+    ).toBe(groups);
+  });
+
+  it('should add a new group on CreateGroup', () => {
+    const previousState: fromReducers.GroupState = {
+      items: [{ config: new ParserGroupModel({ name: 'Existing group' }) }]
+    };
+    const action = new fromActionts.CreateGroup({name: 'New group', description: 'New description'});
+    const newState = fromReducers.groupConfigsReducer(previousState, action);
+
+    expect(newState.items.length).toBe(previousState.items.length + 1);
+    expect(newState.items[0]).toBe(previousState.items[0]);
+    expect(newState.items[1].config.getName()).toBe('New group');
+    expect(newState.items[1].isGroup).toBe(true);
+    expect(newState.items[1].isPhantom).toBe(true);
+  });
+
+  it('should edit an existing group description on UpdateGroupDescription', () => {
+    const previousState: fromReducers.GroupState = {
+      items: [{ config: new ParserGroupModel({ name: 'Existing group', description: 'Existing description' }) }]
+    };
+    const newConfig = {name: 'Existing group', description: 'New description'};
+    const action = new fromActionts.UpdateGroupDescription(newConfig);
+    const newState = fromReducers.groupConfigsReducer(previousState, action);
+
+    expect(newState.items.length).toBe(previousState.items.length);
+    expect(newState.items[0].config.getName()).toBe(newConfig.name);
+    expect(newState.items[0].config.getDescription()).toBe(newConfig.description);
+    expect(newState.items[0].isDirty).toBe(true);
+  });
+
+  it('should mark groups as deleted on MarkAsDeleted', () => {
+    const groupName = 'Existing group';
+    const previousState: fromReducers.GroupState = {
+      items: [{ config: new ParserGroupModel({ name: groupName }) }]
+    };
+    const action = new fromActionts.MarkAsDeleted({
+      parserIds: [groupName]
+    });
+    const newState = fromReducers.groupConfigsReducer(previousState, action);
+
+    expect(newState.items[0].isDeleted).toBe(true);
+  });
+});
+
+describe('sensors: parser statuses reducer', () => {
+
+  it('should return with the initial state by default', () => {
+    expect(
+      fromReducers.parserStatusReducer(undefined, { type: '' })
+    ).toBe(fromReducers.initialStatusState);
+  });
+
+  it('should return with the previous state', () => {
+    const previousState = { items: [] };
+    expect(
+      fromReducers.parserStatusReducer(previousState, { type: '' })
+    ).toBe(previousState);
+  });
+
+  it('should set items on LoadSuccess', () => {
+    const statuses = [];
+    const previousState = {
+      items: []
+    };
+    const action = new fromActionts.LoadSuccess({
+      statuses
+    });
+
+    expect(
+      fromReducers.parserStatusReducer(previousState, action).items
+    ).toBe(statuses);
+  });
+
+  it('should set items on PollStatusSuccess', () => {
+    const statuses = [];
+    const previousState = {
+      items: []
+    };
+    const action = new fromActionts.PollStatusSuccess({
+      statuses
+    });
+
+    expect(
+      fromReducers.parserStatusReducer(previousState, action).items
+    ).toBe(statuses);
+  });
+});
+
+describe('sensors: layout reducer', () => {
+  it('should return with the initial state by default', () => {
+    expect(
+      fromReducers.layoutReducer(undefined, { type: '' })
+    ).toBe(fromReducers.initialLayoutState);
+  });
+
+  it('should return with the previous state', () => {
+    const previousState = { order: [], dnd: {} };
+    expect(
+      fromReducers.layoutReducer(previousState, { type: '' })
+    ).toBe(previousState);
+  });
+
+  it('should set the order on LoadSuccess', () => {
+    const previousState = { order: [], dnd: {} };
+    const action = new fromActionts.LoadSuccess({
+      parsers: [
+        { config: new ParserConfigModel('Parser Config ID 01', { sensorTopic: 'sensor topic 1', group: 'group 2' }) },
+        { config: new ParserConfigModel('Parser Config ID 02', { sensorTopic: 'sensor topic 2', group: 'group 2' }) },
+        { config: new ParserConfigModel('Parser Config ID 03', { sensorTopic: 'sensor topic 3' }) },
+        { config: new ParserConfigModel('Parser Config ID 04', { sensorTopic: 'sensor topic 4', group: 'group 1' }) },
+      ],
+      groups: [
+        { config: new ParserGroupModel({ name: 'group 1' }) },
+        { config: new ParserGroupModel({ name: 'group 2' }) },
+      ]
+    });
+    const newState = fromReducers.layoutReducer(previousState, action);
+    expect(newState.order).not.toBe(previousState.order);
+    expect(newState.order).toEqual([
+      'group 1',
+      'Parser Config ID 04',
+      'group 2',
+      'Parser Config ID 01',
+      'Parser Config ID 02',
+      'Parser Config ID 03',
+    ]);
+  });
+
+  it('should set the draggedId on SetDragged', () => {
+    const previousState = { order: [], dnd: {} };
+    const action = new fromActionts.SetDragged('Foo');
+    const newState = fromReducers.layoutReducer(previousState, action);
+
+    expect(newState.dnd.draggedId).toBe('Foo');
+  });
+
+  it('should set the dropTargetId on SetDropTarget', () => {
+    const previousState = { order: [], dnd: {} };
+    const action = new fromActionts.SetDropTarget('Bar');
+    const newState = fromReducers.layoutReducer(previousState, action);
+
+    expect(newState.dnd.dropTargetId).toBe('Bar');
+  });
+
+  it('should set the targetGroup on SetTargetGroup', () => {
+    const previousState = { order: [], dnd: {} };
+    const action = new fromActionts.SetTargetGroup('Lorem');
+    const newState = fromReducers.layoutReducer(previousState, action);
+
+    expect(newState.dnd.targetGroup).toBe('Lorem');
+  });
+
+  it('should append the group name to the order on CreateGroup', () => {
+    const previousState = { order: [], dnd: {} };
+    const action = new fromActionts.CreateGroup({name: 'Group name', description: 'Group description'});
+    const newState = fromReducers.layoutReducer(previousState, action);
+
+    expect(newState.order[newState.order.length - 1]).toBe('Group name');
+  });
+
+  it('should recalculate the order on AggregateParsers', () => {
+    const previousState = {
+      order: [
+        'group 1',
+        'sensor topic 3',
+        'group 2',
+        'sensor topic 1',
+        'sensor topic 2',
+        'group 4',
+        'sensor topic 4',
+      ],
+      dnd: {}
+    };
+    const action = new fromActionts.AggregateParsers({
+      groupName: 'group 4',
+      parserIds: ['sensor topic 2', 'sensor topic 1']
+    });
+    const newState = fromReducers.layoutReducer(previousState, action);
+
+    expect(newState.order).not.toBe(previousState.order);
+    expect(newState.order).toEqual([
+      'group 1',
+      'sensor topic 3',
+      'group 2',
+      'group 4',
+      'sensor topic 1',
+      'sensor topic 2',
+      'sensor topic 4',
+    ]);
+  });
+
+  it('should inject the order item after the reference item on InjectAfter', () => {
+    const previousState = {
+      order: [
+        'group 1',
+        'group 2',
+        'sensor topic 1',
+        'sensor topic 2',
+        'sensor topic 3',
+        'sensor topic 4',
+      ],
+      dnd: {}
+    };
+    const action = new fromActionts.InjectAfter({
+      parserId: 'sensor topic 1',
+      reference: 'sensor topic 3'
+    });
+    const newState = fromReducers.layoutReducer(previousState, action);
+
+    expect(newState.order).not.toBe(previousState.order);
+    expect(newState.order).toEqual([
+      'group 1',
+      'group 2',
+      'sensor topic 2',
+      'sensor topic 3',
+      'sensor topic 1',
+      'sensor topic 4',
+    ]);
+  });
+
+  it('should inject the order item before the reference item on InjectBefore', () => {
+    const previousState = {
+      order: [
+        'group 1',
+        'group 2',
+        'sensor topic 1',
+        'sensor topic 2',
+        'sensor topic 3',
+        'sensor topic 4',
+      ],
+      dnd: {}
+    };
+    const action = new fromActionts.InjectBefore({
+      parserId: 'sensor topic 4',
+      reference: 'sensor topic 2'
+    });
+    const newState = fromReducers.layoutReducer(previousState, action);
+
+    expect(newState.order).not.toBe(previousState.order);
+    expect(newState.order).toEqual([
+      'group 1',
+      'group 2',
+      'sensor topic 1',
+      'sensor topic 4',
+      'sensor topic 2',
+      'sensor topic 3',
+    ]);
+  });
+});
+
+describe('sensors: selectors', () => {
+
+  it('should return with the sensors substate from the store', () => {
+    const sensors = {
+      parsers: { items: [] },
+      groups: { items: [] },
+      statuses: { items: [] },
+      layout: { order: [], dnd: {} }
+    };
+    const state = { sensors };
+
+    expect(fromReducers.getSensorsState(state))
+      .toBe(sensors);
+  });
+
+  it('should return with the parsers substate from the store', () => {
+    const sensors = {
+      parsers: { items: [] },
+      groups: { items: [] },
+      statuses: { items: [] },
+      layout: { order: [], dnd: {} }
+    };
+    const state = { sensors };
+
+    expect(fromReducers.getParsers(state))
+      .toBe(sensors.parsers.items);
+  });
+
+  it('should return with the groups substate from the store', () => {
+    const sensors = {
+      parsers: { items: [] },
+      groups: { items: [] },
+      statuses: { items: [] },
+      layout: { order: [], dnd: {} }
+    };
+    const state = { sensors };
+
+    expect(fromReducers.getGroups(state))
+      .toBe(sensors.groups.items);
+  });
+
+  it('should return with the statuses substate from the store', () => {
+    const sensors = {
+      parsers: { items: [] },
+      groups: { items: [] },
+      statuses: { items: [] },
+      layout: { order: [], dnd: {} }
+    };
+    const state = { sensors };
+
+    expect(fromReducers.getStatuses(state))
+      .toBe(sensors.statuses.items);
+  });
+
+  it('should return with the order substate from the store', () => {
+    const sensors = {
+      parsers: { items: [] },
+      groups: { items: [] },
+      statuses: { items: [] },
+      layout: { order: [], dnd: {} }
+    };
+    const state = { sensors };
+
+    expect(fromReducers.getLayoutOrder(state))
+      .toBe(sensors.layout.order);
+  });
+
+  it('should return with a merged version of groups, parsers and statuses ordered by the order state', () => {
+    const state = {
+      sensors: {
+        parsers: {
+          items: [
+            { config: new ParserConfigModel('Parser Config ID 01', { sensorTopic: 'Kafka/Sensor Topic ID 1' }) },
+            { config: new ParserConfigModel('Parser Config ID 02', { sensorTopic: 'Kafka/Sensor Topic ID 2', group: 'group 1' }) },
+          ]
+        },
+        groups: {
+          items: [
+            { config: new ParserGroupModel({ name: 'group 1' }) },
+            { config: new ParserGroupModel({ name: 'group 2' }) },
+          ]
+        },
+        statuses: {
+          items: [
+            new TopologyStatus({ name: 'Parser Config ID 02' }),
+            new TopologyStatus({ name: 'Parser Config ID 01' }),
+            new TopologyStatus({ name: 'group 2' }),
+          ]
+        },
+        layout: {
+          order: [
+            'Parser Config ID 02',
+            'Parser Config ID 01',
+            'group 2',
+            'group 1'
+          ],
+          dnd: {}
+        }
+      }
+    };
+
+    const merged = fromReducers.getMergedConfigs(state);
+
+    expect(merged.length).toBe(state.sensors.parsers.items.length + state.sensors.groups.items.length);
+
+    // the reference changes !!
+    expect(merged[0]).not.toBe(state.sensors.parsers.items[1]);
+    expect(merged[1]).not.toBe(state.sensors.parsers.items[0]);
+    expect(merged[2]).not.toBe(state.sensors.groups.items[1]);
+    expect(merged[3]).not.toBe(state.sensors.groups.items[0]);
+
+    // should be ordered by the order state
+    expect(merged[0].config.getName()).toBe(state.sensors.layout.order[0]);
+    expect(merged[1].config.getName()).toBe(state.sensors.layout.order[1]);
+    expect(merged[2].config.getName()).toBe(state.sensors.layout.order[2]);
+    expect(merged[3].config.getName()).toBe(state.sensors.layout.order[3]);
+
+    // make sure they got the status
+    expect(merged[0].status).toEqual(state.sensors.statuses.items[0]);
+    expect(merged[1].status).toEqual(state.sensors.statuses.items[1]);
+    expect(merged[2].status).toEqual(state.sensors.statuses.items[2]);
+
+    // no status belongs to it but got a status instance with no name
+    expect(merged[3].status).toBeTruthy();
+    expect(merged[3].status.name).toBeFalsy();
+  });
+
+  it('should tell if any of the groups or parser configs are dirty', () => {
+    expect(fromReducers.isDirty({
+      sensors: {
+        parsers: {
+          items: [
+            { config: new ParserConfigModel('Parser Config ID 01', { sensorTopic: 'sensor topic 1' }) },
+            { config: new ParserConfigModel('Parser Config ID 01', { sensorTopic: 'sensor topic 2' }) },
+          ]
+        },
+        groups: {
+          items: [
+            { config: new ParserGroupModel({ name: 'group 1' }) },
+            { config: new ParserGroupModel({ name: 'group 2' }) },
+          ]
+        },
+        statuses: { items: [] },
+        layout: { order: [], dnd: {} }
+      }
+    })).toBe(false);
+
+    expect(fromReducers.isDirty({
+      sensors: {
+        parsers: {
+          items: [
+            { config: new ParserConfigModel('Parser Config ID 01', { sensorTopic: 'sensor topic 1' }) },
+            { config: new ParserConfigModel('Parser Config ID 02', { sensorTopic: 'sensor topic 2' }) },
+          ]
+        },
+        groups: {
+          items: [
+            { config: new ParserGroupModel({ name: 'group 1' }), isDeleted: true },
+            { config: new ParserGroupModel({ name: 'group 2' }) },
+          ]
+        },
+        statuses: { items: [] },
+        layout: { order: [], dnd: {} }
+      }
+    })).toBe(true);
+
+    expect(fromReducers.isDirty({
+      sensors: {
+        parsers: {
+          items: [
+            { config: new ParserConfigModel('Parser Config ID 01', { sensorTopic: 'sensor topic 1' }) },
+            { config: new ParserConfigModel('Parser Config ID 02', { sensorTopic: 'sensor topic 2' }), isDeleted: true },
+          ]
+        },
+        groups: {
+          items: [
+            { config: new ParserGroupModel({ name: 'group 1' }) },
+            { config: new ParserGroupModel({ name: 'group 2' }) },
+          ]
+        },
+        statuses: { items: [] },
+        layout: { order: [], dnd: {} }
+      }
+    })).toBe(true);
+
+    expect(fromReducers.isDirty({
+      sensors: {
+        parsers: {
+          items: [
+            { config: new ParserConfigModel('Parser Config ID 01', { sensorTopic: 'sensor topic 1' }) },
+            { config: new ParserConfigModel('Parser Config ID 02', { sensorTopic: 'sensor topic 2' }) },
+          ]
+        },
+        groups: {
+          items: [
+            { config: new ParserGroupModel({ name: 'group 1' }), isDirty: true },
+            { config: new ParserGroupModel({ name: 'group 2' }) },
+          ]
+        },
+        statuses: { items: [] },
+        layout: { order: [], dnd: {} }
+      }
+    })).toBe(true);
+
+    expect(fromReducers.isDirty({
+      sensors: {
+        parsers: {
+          items: [
+            { config: new ParserConfigModel('Parser Config ID 01', { sensorTopic: 'sensor topic 1' }), isDirty: true },
+            { config: new ParserConfigModel('Parser Config ID 02', { sensorTopic: 'sensor topic 2' }) },
+          ]
+        },
+        groups: {
+          items: [
+            { config: new ParserGroupModel({ name: 'group 1' }) },
+            { config: new ParserGroupModel({ name: 'group 2' }) },
+          ]
+        },
+        statuses: { items: [] },
+        layout: { order: [], dnd: {} }
+      }
+    })).toBe(true);
+
+    expect(fromReducers.isDirty({
+      sensors: {
+        parsers: {
+          items: [
+            { config: new ParserConfigModel('Parser Config ID 01', { sensorTopic: 'sensor topic 1' }) },
+            { config: new ParserConfigModel('Parser Config ID 02', { sensorTopic: 'sensor topic 2' }), isPhantom: true },
+          ]
+        },
+        groups: {
+          items: [
+            { config: new ParserGroupModel({ name: 'group 1' }) },
+            { config: new ParserGroupModel({ name: 'group 2' }) },
+          ]
+        },
+        statuses: { items: [] },
+        layout: { order: [], dnd: {} }
+      }
+    })).toBe(true);
+
+    expect(fromReducers.isDirty({
+      sensors: {
+        parsers: {
+          items: [
+            { config: new ParserConfigModel('Parser Config ID 01', { sensorTopic: 'sensor topic 1' }) },
+            { config: new ParserConfigModel('Parser Config ID 02', { sensorTopic: 'sensor topic 2' }) },
+          ]
+        },
+        groups: {
+          items: [
+            { config: new ParserGroupModel({ name: 'group 1' }), isPhantom: true },
+            { config: new ParserGroupModel({ name: 'group 2' }) },
+          ]
+        },
+        statuses: { items: [] },
+        layout: { order: [], dnd: {} }
+      }
+    })).toBe(true);
+  });
+
+  it('should update the parser config in state', () => {
+    const previousState: fromReducers.ParserState = {
+      items: [
+        { config: new ParserConfigModel('bar', { sensorTopic: 'bar' }) },
+        { config: new ParserConfigModel('foo', { sensorTopic: 'foo' }) },
+      ]
+    };
+    const action = new fromActionts.UpdateParserConfig(
+      new ParserConfigModel('foo', { sensorTopic: 'foo updated' })
+    );
+    const newState = fromReducers.parserConfigsReducer(previousState, action);
+    const updated = newState.items.find(item => item.config.getName() === 'foo');
+    expect((updated.config as ParserConfigModel).sensorTopic).toBe('foo updated');
+  });
+
+  it('should add a new parser config', () => {
+    const previousState: fromReducers.ParserState = {
+      items: [
+        { config: new ParserConfigModel('bar', { sensorTopic: 'bar' }) },
+        { config: new ParserConfigModel('foo', { sensorTopic: 'foo' }) },
+      ]
+    };
+    const action = new fromActionts.AddParserConfig(
+      new ParserConfigModel('baz', { sensorTopic: 'baz new' })
+    );
+    const newState = fromReducers.parserConfigsReducer(previousState, action);
+    expect((newState.items[2].config as ParserConfigModel).id).toBe('baz');
+    expect((newState.items[2].config as ParserConfigModel).sensorTopic).toBe('baz new');
+  });
+
+  it('should add a new parser config in the order', () => {
+    const previousState: fromReducers.LayoutState = {
+      order: [
+        'bar', 'foo'
+      ],
+      dnd: {}
+    };
+    const action = new fromActionts.AddParserConfig(
+      new ParserConfigModel('baz', { sensorTopic: 'baz new' })
+    );
+    const newState = fromReducers.layoutReducer(previousState, action);
+    expect(newState.order[2]).toBe('baz');
+  });
+});
diff --git a/metron-interface/metron-config/src/app/sensors/reducers/sensors.reducers.ts b/metron-interface/metron-config/src/app/sensors/reducers/sensors.reducers.ts
new file mode 100644
index 0000000..800c8f4
--- /dev/null
+++ b/metron-interface/metron-config/src/app/sensors/reducers/sensors.reducers.ts
@@ -0,0 +1,638 @@
+/**
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+import { Action, createSelector, createFeatureSelector } from '@ngrx/store';
+import { TopologyStatus } from '../../model/topology-status';
+import { ParserGroupModel } from '../models/parser-group.model';
+import { ParserMetaInfoModel } from '../models/parser-meta-info.model';
+import * as fromActions from '../actions';
+import { State, SensorState } from './';
+import { ParserConfigModel } from '../models/parser-config.model';
+
+export interface ParserState {
+  items: ParserMetaInfoModel[];
+}
+
+export interface GroupState {
+  items: ParserMetaInfoModel[];
+}
+
+export interface StatusState {
+  items: TopologyStatus[];
+}
+
+export interface DragNDropState {
+  draggedId?: string,
+  dropTargetId?: string,
+  targetGroup?: string,
+}
+
+export interface LayoutState {
+  order: string[],
+  dnd: DragNDropState
+}
+
+export const initialParserState: ParserState = {
+  items: []
+}
+
+export const initialGroupState: GroupState = {
+  items: []
+}
+
+export const initialStatusState: StatusState = {
+  items: []
+}
+
+export const initialLayoutState: LayoutState = {
+  order: [],
+  dnd: {
+    draggedId: '',
+    dropTargetId: '',
+    targetGroup: ''
+  }
+}
+
+export function parserConfigsReducer(state: ParserState = initialParserState, action: Action): ParserState {
+  switch (action.type) {
+    case fromActions.SensorsActionTypes.LoadSuccess:
+      return {
+        ...state,
+        items: (action as fromActions.LoadSuccess).payload.parsers
+      };
+
+    case fromActions.SensorsActionTypes.UpdateParserConfig: {
+      const a = (action as fromActions.UpdateParserConfig);
+      return {
+        ...state,
+        items: state.items.map(item => {
+          if (item.config.getName() === a.payload.getName()) {
+            return {
+              ...item,
+              config: a.payload.clone()
+            };
+          }
+          return item;
+        }),
+      };
+    }
+
+    case fromActions.SensorsActionTypes.AddParserConfig: {
+      const a = (action as fromActions.AddParserConfig);
+      return {
+        ...state,
+        items: [
+          ...state.items,
+          {
+            config: (a.payload as ParserConfigModel).clone(),
+          }
+        ]
+      };
+    }
+
+    case fromActions.SensorsActionTypes.AggregateParsers:
+    case fromActions.SensorsActionTypes.AddToGroup: {
+      const a = (action as fromActions.AggregateParsers);
+      return {
+        ...state,
+        items: state.items.map(item => {
+          if (a.payload.parserIds.includes(item.config.getName())) {
+            if (item.config.group !== a.payload.groupName) {
+              const config = (item.config as ParserConfigModel).clone();
+              config.group = a.payload.groupName;
+              return {
+                ...item,
+                isDirty: true,
+                config,
+              };
+            }
+          }
+          return item;
+        })
+      };
+    }
+
+    case fromActions.SensorsActionTypes.MarkAsDeleted: {
+      const a = (action as fromActions.MarkAsDeleted);
+      return {
+        ...state,
+        items: state.items.map(item => {
+          if (a.payload.parserIds.includes(item.config.getName())) {
+            item = {
+              ...item,
+              isDeleted: true
+            };
+          }
+          if (a.payload.parserIds.includes(item.config.group)) {
+            if (item.config.group) {
+              const config = (item.config as ParserConfigModel).clone();
+              config.group = '';
+              item = {
+                ...item,
+                isDirty: true,
+                config,
+              };
+            }
+          }
+          return item;
+        })
+      }
+    }
+
+    case fromActions.SensorsActionTypes.StartSensor:
+    case fromActions.SensorsActionTypes.StopSensor:
+    case fromActions.SensorsActionTypes.EnableSensor:
+    case fromActions.SensorsActionTypes.DisableSensor: {
+      const a = action as fromActions.SensorControlAction;
+      return {
+        ...state,
+        items: state.items.map((item) => {
+          if (a.payload.parser.config.getName() === item.config.getName()) {
+            return {
+              ...item,
+              startStopInProgress: true
+            };
+          }
+          return item;
+        })
+      };
+    }
+
+    case fromActions.SensorsActionTypes.StartSensorSuccess:
+    case fromActions.SensorsActionTypes.StartSensorFailure:
+    case fromActions.SensorsActionTypes.StopSensorSuccess:
+    case fromActions.SensorsActionTypes.StopSensorFailure:
+    case fromActions.SensorsActionTypes.EnableSensorSuccess:
+    case fromActions.SensorsActionTypes.EnableSensorFailure:
+    case fromActions.SensorsActionTypes.DisableSensorSuccess:
+    case fromActions.SensorsActionTypes.DisableSensorFailure: {
+      const a = action as fromActions.SensorControlResponseAction;
+      return {
+        ...state,
+        items: state.items.map((item) => {
+          if (a.payload.parser.config.getName() === item.config.getName()) {
+            return {
+              ...item,
+              startStopInProgress: false
+            };
+          }
+          return item;
+        })
+      };
+    }
+
+    default:
+      return state;
+  }
+}
+
+export function groupConfigsReducer(state: GroupState = initialGroupState, action: Action): GroupState {
+  switch (action.type) {
+    case fromActions.SensorsActionTypes.LoadSuccess:
+      return {
+        ...state,
+        items: (action as fromActions.LoadSuccess).payload.groups
+      }
+    case fromActions.SensorsActionTypes.CreateGroup: {
+      const a = (action as fromActions.CreateGroup);
+      const group = {
+        config: new ParserGroupModel({ name: a.payload.name, description: a.payload.description }),
+        isGroup: true,
+        isPhantom: true,
+      };
+      return {
+        ...state,
+        items: [
+          ...state.items,
+          group
+        ]
+      }
+    }
+    case fromActions.SensorsActionTypes.AddToGroup: {
+      const a = (action as fromActions.AddToGroup);
+      const groupName = a.payload.groupName;
+      const parserIds = a.payload.parserIds;
+      if (groupName === '') {
+        return {
+          ...state,
+          items: state.items.map(item => {
+            let config = item.config as ParserGroupModel;
+            let changed;
+            parserIds.forEach(id => {
+              if (config.sensors.includes(id)) {
+                config = config.clone({
+                  sensors: config.sensors.filter(sensor => sensor !== id),
+                });
+                changed = true;
+              }
+            });
+            return {
+              ...item,
+              config,
+              isDirty: typeof changed === 'undefined' ? item.isDirty : changed,
+            }
+          })
+        };
+      } else {
+        return {
+          ...state,
+          items: state.items.map(item => {
+            const config = item.config as ParserGroupModel;
+            if (config.getName() === groupName) {
+              const newConfig = config.clone({
+                name: groupName,
+                sensors: [...config.sensors, ...parserIds],
+              });
+              return {
+                ...item,
+                config: newConfig,
+                isDirty: true
+              };
+            }
+            return item;
+          })
+        };
+      }
+    }
+    case fromActions.SensorsActionTypes.AggregateParsers: {
+      const a = (action as fromActions.AggregateParsers);
+      const groupName = a.payload.groupName;
+      const parserIds = a.payload.parserIds;
+      return {
+        ...state,
+        items: state.items.map(item => {
+          const config = item.config as ParserGroupModel;
+          if (config.getName() === groupName) {
+            const newConfig = config.clone({
+              name: groupName,
+              sensors: [...config.sensors, ...parserIds]
+            });
+            return {
+              ...item,
+              config: newConfig,
+              isDirty: true
+            };
+          }
+          return item;
+        })
+      };
+    }
+    case fromActions.SensorsActionTypes.UpdateGroupDescription: {
+      const a = (action as fromActions.UpdateGroupDescription);
+      return {
+        ...state,
+        items: state.items.map(item => {
+          if (a.payload.name === item.config.getName()) {
+            const config = (item.config as ParserGroupModel).clone(a.payload);
+            config.setDescription(a.payload.description);
+            return {
+              ...item,
+              config,
+              isDirty: true
+            }
+          }
+          return item;
+        })
+      }
+    }
+    case fromActions.SensorsActionTypes.MarkAsDeleted: {
+      const a = (action as fromActions.MarkAsDeleted);
+      return {
+        ...state,
+        items: state.items.map(item => {
+          if (a.payload.parserIds.includes(item.config.getName())) {
+            return {
+              ...item,
+              isDeleted: true
+            };
+          }
+          return item;
+        })
+      }
+    }
+    case fromActions.SensorsActionTypes.StartSensor:
+    case fromActions.SensorsActionTypes.StopSensor:
+    case fromActions.SensorsActionTypes.EnableSensor:
+    case fromActions.SensorsActionTypes.DisableSensor: {
+      const a = action as fromActions.SensorControlAction;
+      return {
+        ...state,
+        items: state.items.map((item) => {
+          if (a.payload.parser.config.getName() === item.config.getName()) {
+            return {
+              ...item,
+              startStopInProgress: true
+            };
+          }
+          return item;
+        })
+      };
+    }
+
+    case fromActions.SensorsActionTypes.StartSensorSuccess:
+    case fromActions.SensorsActionTypes.StartSensorFailure:
+    case fromActions.SensorsActionTypes.StopSensorSuccess:
+    case fromActions.SensorsActionTypes.StopSensorFailure:
+    case fromActions.SensorsActionTypes.EnableSensorSuccess:
+    case fromActions.SensorsActionTypes.EnableSensorFailure:
+    case fromActions.SensorsActionTypes.DisableSensorSuccess:
+    case fromActions.SensorsActionTypes.DisableSensorFailure: {
+      const a = action as fromActions.SensorControlResponseAction;
+      return {
+        ...state,
+        items: state.items.map((item) => {
+          if (a.payload.parser.config.getName() === item.config.getName()) {
+            return {
+              ...item,
+              startStopInProgress: false
+            };
+          }
+          return item;
+        })
+      };
+    }
+
+    default:
+      return state;
+  }
+}
+
+export function parserStatusReducer(state: StatusState = initialStatusState, action: Action): StatusState {
+  switch (action.type) {
+    case fromActions.SensorsActionTypes.LoadSuccess:
+    case fromActions.SensorsActionTypes.PollStatusSuccess: {
+      return {
+        ...state,
+        items: (action as fromActions.LoadSuccess).payload.statuses
+      }
+    }
+
+    default:
+      return state;
+  }
+}
+
+export function layoutReducer(state: LayoutState = initialLayoutState, action: Action): LayoutState {
+  switch (action.type) {
+    case fromActions.SensorsActionTypes.LoadSuccess: {
+      const payload = (action as fromActions.LoadSuccess).payload;
+      const groups: ParserMetaInfoModel[] = payload.groups;
+      const parsers: ParserMetaInfoModel[] = payload.parsers;
+      let order: string[] = [];
+      groups.forEach((group) => {
+        order = order.concat(group.config.getName());
+        const configsForGroup = parsers
+          .filter(parser => parser.config && parser.config.group === group.config.getName())
+          .map(parser => parser.config.getName());
+          order = order.concat(configsForGroup);
+      });
+
+      order = order.concat(
+        parsers
+          .filter(parser => !parser.config.group)
+          .map(parser => parser.config.getName())
+        );
+
+      return {
+        ...state,
+        order
+      };
+    }
+
+    case fromActions.SensorsActionTypes.SetDragged: {
+
+      return {
+        ...state,
+        dnd: {
+          ...state.dnd,
+          draggedId: (action as fromActions.SetDragged).payload
+        }
+      };
+    }
+
+    case fromActions.SensorsActionTypes.SetDropTarget: {
+
+      return {
+        ...state,
+        dnd: {
+          ...state.dnd,
+          dropTargetId: (action as fromActions.SetDropTarget).payload
+        }
+      };
+    }
+
+    case fromActions.SensorsActionTypes.SetTargetGroup: {
+
+      return {
+        ...state,
+        dnd: {
+          ...state.dnd,
+          targetGroup: (action as fromActions.SetTargetGroup).payload
+        }
+      };
+    }
+
+    case fromActions.SensorsActionTypes.CreateGroup: {
+      const a = (action as fromActions.CreateGroup);
+      return {
+        ...state,
+        order: [
+          ...state.order,
+          a.payload.name
+        ]
+      };
+    }
+
+    case fromActions.SensorsActionTypes.AggregateParsers: {
+      let order = state.order.slice(0);
+      const a = (action as fromActions.AggregateParsers);
+      const reference: string = a.payload.parserIds[0];
+      const referenceIndex = order.indexOf(reference);
+      const dragged: string = a.payload.parserIds[1];
+
+      order = order.map(id => {
+        if (id === a.payload.groupName || id === dragged) {
+          return null;
+        }
+        return id;
+      });
+      order.splice(referenceIndex, 0, a.payload.groupName);
+      order.splice(referenceIndex + 1, 0, dragged);
+
+      order = order.filter(Boolean);
+
+      return {
+        ...state,
+        order,
+      }
+    }
+
+    case fromActions.SensorsActionTypes.InjectAfter: {
+      let order = state.order.slice(0);
+      const a = (action as fromActions.InjectAfter);
+      const referenceIndex = order.indexOf(a.payload.reference);
+
+      order = order.map(id => {
+        if (id === a.payload.parserId) {
+          return null;
+        }
+        return id;
+      });
+
+      order.splice(referenceIndex + 1, 0, a.payload.parserId);
+
+      order = order.filter(Boolean);
+
+      return {
+        ...state,
+        order
+      };
+    }
+
+    case fromActions.SensorsActionTypes.InjectBefore: {
+      let order = state.order.slice(0);
+      const a = (action as fromActions.InjectBefore);
+      const referenceIndex = order.indexOf(a.payload.reference);
+
+      order = order.map(id => {
+        if (id === a.payload.parserId) {
+          return null;
+        }
+        return id;
+      });
+
+      order.splice(referenceIndex, 0, a.payload.parserId);
+
+      order = order.filter(Boolean);
+
+      return {
+        ...state,
+        order
+      };
+    }
+
+    case fromActions.SensorsActionTypes.AddParserConfig: {
+      const a = (action as fromActions.AddParserConfig);
+      return {
+        ...state,
+        order: [
+          ...state.order,
+          a.payload.getName(),
+        ]
+      };
+    }
+
+    default:
+      return state;
+  }
+}
+
+/**
+ * Selectors
+ */
+
+ export const getSensorsState = createFeatureSelector<State, SensorState>('sensors');
+
+export const getGroups = createSelector(
+  getSensorsState,
+  (state: SensorState): ParserMetaInfoModel[] => {
+    return state.groups.items;
+  }
+);
+
+export const getGroupByName = createSelector(
+  getGroups,
+  (groups) => (name: string): ParserMetaInfoModel => {
+    return groups.find((group: ParserMetaInfoModel) => group.config.getName() === name);
+  }
+);
+
+export const getParsers = createSelector(
+  getSensorsState,
+  (state: SensorState): ParserMetaInfoModel[] => {
+    return state.parsers.items;
+  }
+);
+
+export const getStatuses = createSelector(
+  getSensorsState,
+  (state: SensorState): TopologyStatus[] => {
+    return state.statuses.items;
+  }
+);
+
+export const getLayoutOrder = createSelector(
+  getSensorsState,
+  (state: SensorState): string[] => {
+    return state.layout.order;
+  }
+);
+
+export const getMergedConfigs = createSelector(
+  getGroups,
+  getParsers,
+  getStatuses,
+  getLayoutOrder,
+  (
+    groups: ParserMetaInfoModel[],
+    parsers: ParserMetaInfoModel[],
+    statuses: TopologyStatus[],
+    order: string[]
+  ): ParserMetaInfoModel[] => {
+    let result: ParserMetaInfoModel[] = [];
+    result = order.map((id: string) => {
+      const group = groups.find(g => g.config.getName() === id);
+      if (group) {
+        return group;
+      }
+      const parserConfig = parsers.find(p => p.config.getName() === id);
+      if (parserConfig) {
+        return parserConfig;
+      }
+      return null;
+    }).filter(Boolean);
+
+    result = result.map((item) => {
+      let status: TopologyStatus = statuses.find(stat => {
+        return stat.name === item.config.getName();
+      });
+      return {
+        ...item,
+        status: status ? new TopologyStatus(status) : new TopologyStatus(),
+        isRunning: status ? status.status === 'ACTIVE' : false,
+      };
+    });
+
+    return result;
+  }
+);
+
+export const isDirty = createSelector(
+  getGroups,
+  getParsers,
+  (groups: ParserMetaInfoModel[], parsers: ParserMetaInfoModel[]): boolean => {
+    const isChanged = (item) => item.isDeleted || item.isDirty || item.isPhantom;
+    return groups.some(isChanged) || parsers.some(isChanged)
+  }
+);
+
+export const getParserConfig = () => createSelector(
+  getParsers,
+  (parsers: ParserMetaInfoModel[], props) => {
+    return parsers.find(parser => parser.config.getName() === props.id);
+  }
+);