From 7953b008a8b9c6d68c33ca88de29d85f23ca380c Mon Sep 17 00:00:00 2001
From: ygzaydn <32926423+ygzaydn@users.noreply.github.com>
Date: Wed, 23 Sep 2026 05:21:37 +0300
Subject: [PATCH] [EIR] add WebUI equipment identity management (#4795)
* feat(eir): add WebUI equipment identity management
* fix(eir): align WebUI rule validation and sample test setup
* fix(eir): keep EIR disabled in sample configuration
---
tests/app/app-init.c | 6 +
webui/server/models/eir.js | 19 +++
webui/server/routes/db.js | 3 +
webui/src/components/Base/Sidebar.js | 5 +
webui/src/components/Eir/Edit.js | 74 ++++++++++
webui/src/components/Eir/Item.js | 115 +++++++++++++++
webui/src/components/Eir/List.js | 47 ++++++
webui/src/components/Eir/Search.js | 52 +++++++
webui/src/components/Eir/View.js | 190 +++++++++++++++++++++++++
webui/src/components/Eir/index.js | 5 +
webui/src/components/index.js | 4 +-
webui/src/containers/App.js | 6 +-
webui/src/containers/Eir/Collection.js | 148 +++++++++++++++++++
webui/src/containers/Eir/Document.js | 126 ++++++++++++++++
webui/src/containers/Eir/index.js | 3 +
webui/src/modules/crud/eir.js | 19 +++
16 files changed, 820 insertions(+), 2 deletions(-)
create mode 100644 webui/server/models/eir.js
create mode 100644 webui/src/components/Eir/Edit.js
create mode 100644 webui/src/components/Eir/Item.js
create mode 100644 webui/src/components/Eir/List.js
create mode 100644 webui/src/components/Eir/Search.js
create mode 100644 webui/src/components/Eir/View.js
create mode 100644 webui/src/components/Eir/index.js
create mode 100644 webui/src/containers/Eir/Collection.js
create mode 100644 webui/src/containers/Eir/Document.js
create mode 100644 webui/src/containers/Eir/index.js
create mode 100644 webui/src/modules/crud/eir.js
diff --git a/tests/app/app-init.c b/tests/app/app-init.c
index 0139c5c29..72c8607f7 100644
--- a/tests/app/app-init.c
+++ b/tests/app/app-init.c
@@ -36,6 +36,7 @@ static ogs_thread_t *pcf_thread = NULL;
static ogs_thread_t *nssf_thread = NULL;
static ogs_thread_t *bsf_thread = NULL;
static ogs_thread_t *udr_thread = NULL;
+static ogs_thread_t *eir_thread = NULL;
int app_initialize(const char *const argv[])
{
@@ -96,6 +97,10 @@ int app_initialize(const char *const argv[])
if (ogs_global_conf()->parameter.no_udr == 0)
udr_thread = test_child_create("udr", 0, argv_out);
+ if (ogs_global_conf()->parameter.no_eir == 0 &&
+ ogs_global_conf()->parameter.eir_count > 0)
+ eir_thread = test_child_create("eir", 0, argv_out);
+
/*
* Wait for all sockets listening
*
@@ -118,6 +123,7 @@ void app_terminate(void)
if (upf_thread) ogs_thread_destroy(upf_thread);
if (udr_thread) ogs_thread_destroy(udr_thread);
+ if (eir_thread) ogs_thread_destroy(eir_thread);
if (bsf_thread) ogs_thread_destroy(bsf_thread);
if (nssf_thread) ogs_thread_destroy(nssf_thread);
if (pcf_thread) ogs_thread_destroy(pcf_thread);
diff --git a/webui/server/models/eir.js b/webui/server/models/eir.js
new file mode 100644
index 000000000..26152183d
--- /dev/null
+++ b/webui/server/models/eir.js
@@ -0,0 +1,19 @@
+const mongoose = require('mongoose');
+const Schema = mongoose.Schema;
+const Eir = new Schema(
+ {
+ pei: {
+ $type: String,
+ required: true,
+ match: /^(imei-[0-9]{15}|imeisv-[0-9]{16})$/
+ },
+ supi: { $type: String, default: undefined, match: /^imsi-[0-9]{6,15}$/ },
+ status: {
+ $type: String,
+ required: true,
+ enum: ['WHITELISTED', 'BLACKLISTED', 'GREYLISTED']
+ }
+ },
+ { typeKey: '$type', collection: 'eir' }
+);
+module.exports = mongoose.model('Eir', Eir);
diff --git a/webui/server/routes/db.js b/webui/server/routes/db.js
index 751a0f3dc..70570886c 100644
--- a/webui/server/routes/db.js
+++ b/webui/server/routes/db.js
@@ -10,6 +10,9 @@ restify.serve(router, Subscriber, {
idProperty: 'imsi'
});
+const Eir = require('../models/eir');
+restify.serve(router, Eir, { prefix: '', version: '' });
+
const Profile = require('../models/profile');
restify.serve(router, Profile, {
prefix: '',
diff --git a/webui/src/components/Base/Sidebar.js b/webui/src/components/Base/Sidebar.js
index 521b4bcb3..c8077664a 100644
--- a/webui/src/components/Base/Sidebar.js
+++ b/webui/src/components/Base/Sidebar.js
@@ -7,6 +7,7 @@ import oc from 'open-color';
import SubscriberIcon from 'react-icons/lib/md/person-add';
import ProfileIcon from 'react-icons/lib/md/content-copy';
import AccountIcon from 'react-icons/lib/md/vpn-key';
+import EirIcon from 'react-icons/lib/md/phonelink-lock';
const Menu = styled.div`
display: block;
@@ -85,6 +86,10 @@ const Sidebar = ({ isOpen, width, selectedView, onSelectView }) => (
Subscriber
+ -
+
+ EIR
+
-
Profile
diff --git a/webui/src/components/Eir/Edit.js b/webui/src/components/Eir/Edit.js
new file mode 100644
index 000000000..5c6b1ee07
--- /dev/null
+++ b/webui/src/components/Eir/Edit.js
@@ -0,0 +1,74 @@
+import { Component } from 'react';
+import withWidth, { SMALL } from 'helpers/with-width';
+import { Form } from 'components';
+const schema = {
+ title: '',
+ type: 'object',
+ properties: {
+ pei_type: {
+ type: 'string',
+ title: 'Equipment Identity Type*',
+ enum: ['imei', 'imeisv'],
+ enumNames: ['IMEI (15 digits)', 'IMEISV (16 digits)'],
+ default: 'imei'
+ },
+ pei: {
+ type: 'string',
+ title: 'IMEI / IMEISV*',
+ required: true,
+ pattern: '^\\d+$',
+ messages: { pattern: 'Only digits are allowed' }
+ },
+ imsi: {
+ type: 'string',
+ title: 'IMSI (optional subscriber rule)',
+ pattern: '^\\d*$',
+ messages: { pattern: 'Only digits are allowed' }
+ },
+ status: {
+ type: 'string',
+ title: 'Equipment Status*',
+ required: true,
+ enum: ['WHITELISTED', 'GREYLISTED', 'BLACKLISTED'],
+ default: 'WHITELISTED'
+ }
+ }
+};
+class Edit extends Component {
+ constructor(props) {
+ super(props);
+ this.state = this.stateFrom(props);
+ }
+ componentWillReceiveProps(props) {
+ this.setState(this.stateFrom(props));
+ }
+ stateFrom(props) {
+ const uiSchema = {};
+ if (props.action === 'update')
+ ['pei_type', 'pei', 'imsi'].forEach(
+ (key) => (uiSchema[key] = { 'ui:disabled': true })
+ );
+ else if (props.width !== SMALL) uiSchema.pei = { 'ui:autofocus': true };
+ return { formData: props.formData, uiSchema };
+ }
+ render() {
+ const p = this.props;
+ return (
+
+ );
+ }
+}
+export default withWidth()(Edit);
diff --git a/webui/src/components/Eir/Item.js b/webui/src/components/Eir/Item.js
new file mode 100644
index 000000000..008b50e8c
--- /dev/null
+++ b/webui/src/components/Eir/Item.js
@@ -0,0 +1,115 @@
+import { Component } from 'react';
+import styled from 'styled-components';
+import oc from 'open-color';
+import { media } from 'helpers/style-utils';
+import EditIcon from 'react-icons/lib/md/edit';
+import DeleteIcon from 'react-icons/lib/md/delete';
+import { Tooltip, Spinner } from 'components';
+
+const Sizer = styled.div`
+ display: inline-block;
+ width: 33.3%;
+ padding: 0.5rem;
+ ${(p) => p.disabled && 'opacity: 0.5; cursor: not-allowed;'};
+ ${media.desktop`width: 50%;`} ${media.tablet`width: 100%;`}
+`;
+const Card = styled.div`
+ position: relative;
+ display: flex;
+ background: white;
+ box-shadow: 0 1px 3px rgba(0, 0, 0, 0.12), 0 1px 2px rgba(0, 0, 0, 0.24);
+ transition: all 0.3s cubic-bezier(0.25, 0.8, 0.25, 1);
+ cursor: pointer;
+ ${(p) => p.disabled && 'pointer-events: none;'}
+ .actions {
+ position: absolute;
+ top: 0;
+ right: 0;
+ width: 6rem;
+ height: 100%;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ opacity: 0;
+ }
+ &:hover {
+ box-shadow: 0 3px 6px rgba(0, 0, 0, 0.16), 0 3px 6px rgba(0, 0, 0, 0.23);
+ .actions {
+ ${(p) => (p.disabled ? 'opacity: 0;' : 'opacity: 1;')};
+ }
+ }
+`;
+const Pei = styled.div`
+ padding-left: 1rem;
+ color: ${oc.gray[8]};
+ font-size: 1.25rem;
+ line-height: 3rem;
+`;
+const CircleButton = styled.div`
+ height: 2rem;
+ width: 2rem;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ margin: 1px;
+ background: white;
+ color: ${oc.gray[6]};
+ border-radius: 1rem;
+ font-size: 1.5rem;
+ &:hover {
+ color: ${oc.indigo[6]};
+ }
+ &.delete:hover {
+ color: ${oc.pink[6]};
+ }
+`;
+const SpinnerWrapper = styled.div`
+ position: absolute;
+ top: 0;
+ right: 0;
+ width: 4rem;
+ height: 100%;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+`;
+const displayPei = (pei) => (pei || '').replace(/^(imei|imeisv)-/, '');
+
+class Item extends Component {
+ handleEdit = (e) => {
+ e.stopPropagation();
+ this.props.onEdit(this.props.record._id);
+ };
+ handleDelete = (e) => {
+ e.stopPropagation();
+ this.props.onDelete(this.props.record._id);
+ };
+ render() {
+ const { disabled, record, onView } = this.props;
+ return (
+
+ onView(record._id)}>
+ {displayPei(record.pei)}
+
+
+
+
+
+
+
+
+
+
+
+
+ {disabled && (
+
+
+
+ )}
+
+
+ );
+ }
+}
+export default Item;
diff --git a/webui/src/components/Eir/List.js b/webui/src/components/Eir/List.js
new file mode 100644
index 000000000..a72211b7d
--- /dev/null
+++ b/webui/src/components/Eir/List.js
@@ -0,0 +1,47 @@
+import styled from 'styled-components';
+import { media, transitions } from 'helpers/style-utils';
+import CSSTransitionGroup from 'react-transition-group/CSSTransitionGroup';
+import Item from './Item';
+const Wrapper = styled.div`
+ display: block;
+ margin: 1rem 0.5rem;
+ ${media.mobile`margin:.5rem .25rem;`}.eir-enter {
+ animation: ${transitions.stretchOut} 0.3s ease-in;
+ animation-fill-mode: forwards;
+ }
+ .eir-leave {
+ animation: ${transitions.shrinkIn} 0.15s ease-in;
+ animation-fill-mode: forwards;
+ }
+`;
+const raw = (value) => (value || '').replace(/^(imei|imeisv|imsi)-/, '');
+export default ({ records, deletedId, onView, onEdit, onDelete, search }) => {
+ const list = records
+ .filter(
+ (record) =>
+ raw(record.pei).indexOf(search) !== -1 ||
+ raw(record.supi).indexOf(search) !== -1
+ )
+ .sort((a, b) => raw(a.pei).localeCompare(raw(b.pei)))
+ .map((record) => (
+
+ ));
+ return (
+
+
+ {list}
+
+
+ );
+};
diff --git a/webui/src/components/Eir/Search.js b/webui/src/components/Eir/Search.js
new file mode 100644
index 000000000..873db6389
--- /dev/null
+++ b/webui/src/components/Eir/Search.js
@@ -0,0 +1,52 @@
+import styled from 'styled-components';
+import oc from 'open-color';
+import { media } from 'helpers/style-utils';
+import SearchIcon from 'react-icons/lib/md/search';
+import ClearIcon from 'react-icons/lib/md/clear';
+const Wrapper = styled.div`
+ display: flex;
+ align-items: center;
+ width: 700px;
+ margin: 2rem auto 1rem;
+ background: white;
+ color: ${oc.gray[6]};
+ box-shadow: 0 1px 3px rgba(0, 0, 0, 0.12), 0 1px 2px rgba(0, 0, 0, 0.24);
+ transition: all 0.3s cubic-bezier(0.25, 0.8, 0.25, 1);
+ &:hover {
+ box-shadow: 0 10px 20px rgba(0, 0, 0, 0.19), 0 6px 6px rgba(0, 0, 0, 0.23);
+ }
+ ${media.tablet`width:400px;`}${media.mobile`margin:0 auto;width:100%;`}
+`;
+const Input = styled.input`
+ padding: 1rem;
+ margin: 0 auto;
+ width: 100%;
+ font-size: 1.5rem;
+ cursor: text;
+ border: none;
+ outline: none;
+`;
+const Icon = styled.div`
+ display: inline-flex;
+ margin-left: 1rem;
+ font-size: 1.5rem;
+`;
+const Clear = styled.div`
+ display: inline-flex;
+ margin-right: 1rem;
+ font-size: 1.5rem;
+ cursor: pointer;
+`;
+export default ({ value, onChange, onClear }) => (
+
+
+
+
+
+ {value !== '' && (
+
+
+
+ )}
+
+);
diff --git a/webui/src/components/Eir/View.js b/webui/src/components/Eir/View.js
new file mode 100644
index 000000000..064992a51
--- /dev/null
+++ b/webui/src/components/Eir/View.js
@@ -0,0 +1,190 @@
+import styled from 'styled-components';
+import oc from 'open-color';
+import { media } from 'helpers/style-utils';
+import EditIcon from 'react-icons/lib/md/edit';
+import DeleteIcon from 'react-icons/lib/md/delete';
+import CloseIcon from 'react-icons/lib/md/close';
+import SecurityIcon from 'react-icons/lib/md/security';
+import PhoneIcon from 'react-icons/lib/md/phone';
+import KeyboardControlIcon from 'react-icons/lib/md/keyboard-control';
+import { Modal, Tooltip } from 'components';
+const Wrapper = styled.div`
+ display: flex;
+ flex-direction: column;
+ position: relative;
+ width: 700px;
+ ${media.mobile`width:calc(100vw - 4rem);`}background:white;
+ box-shadow: 0 10px 20px rgba(0, 0, 0, 0.19), 0 6px 6px rgba(0, 0, 0, 0.23);
+`;
+const Header = styled.div`
+ position: relative;
+ display: flex;
+ background: ${oc.gray[1]};
+ .title {
+ padding: 1.5rem;
+ color: ${oc.gray[8]};
+ font-size: 1.5rem;
+ }
+ .actions {
+ position: absolute;
+ top: 0;
+ right: 0;
+ width: 8rem;
+ height: 100%;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ }
+`;
+const CircleButton = styled.div`
+ height: 2rem;
+ width: 2rem;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ margin: 1px;
+ color: ${oc.gray[6]};
+ border-radius: 1rem;
+ font-size: 1.5rem;
+ &:hover {
+ color: ${oc.indigo[6]};
+ }
+ &.delete:hover {
+ color: ${oc.pink[6]};
+ }
+`;
+const Body = styled.div`
+ display: block;
+ margin: 0.5rem;
+ min-height: 190px;
+ overflow: scroll;
+`;
+const Details = styled.div`
+ display: flex;
+ flex-direction: column;
+ margin: 0 auto;
+ color: ${oc.gray[9]};
+ .header {
+ margin: 12px;
+ font-size: 16px;
+ }
+ .body {
+ display: flex;
+ flex-direction: row;
+ flex: 1;
+ margin: 6px;
+ }
+ .left {
+ width: 80px;
+ text-align: center;
+ font-size: 18px;
+ color: ${oc.gray[6]};
+ }
+ .right {
+ display: flex;
+ flex-direction: column;
+ flex: 1;
+ }
+ .data {
+ flex: 1;
+ font-size: 12px;
+ margin: 4px;
+ }
+`;
+const raw = (value) => (value || '').replace(/^(imei|imeisv|imsi)-/, '');
+export default ({
+ visible,
+ disableOnClickOutside,
+ record,
+ onEdit,
+ onDelete,
+ onHide
+}) => {
+ const r = record || {};
+ return (
+
+
+
+
+
+ Equipment Identity Rule
+
+
+
+
+ {(r.pei || '').split('-')[0].toUpperCase()}
+
+
+ Identity Type
+
+
+
+ {raw(r.pei)}
+
+
+ IMEI / IMEISV
+
+
+ {r.supi && (
+
+ {raw(r.supi)}
+
+
+ IMSI
+
+
+ )}
+
+
+
+
+
+
+
+
+ {r.supi ? 'Subscriber Rule' : 'Device Rule'}
+
+
+ Rule Type
+
+
+
+ {r.status}
+
+
+ Equipment Status
+
+
+
+
+
+
+
+
+ );
+};
diff --git a/webui/src/components/Eir/index.js b/webui/src/components/Eir/index.js
new file mode 100644
index 000000000..c845bd1ba
--- /dev/null
+++ b/webui/src/components/Eir/index.js
@@ -0,0 +1,5 @@
+import List from './List';
+import Search from './Search';
+import Edit from './Edit';
+import View from './View';
+export { List, Search, Edit, View };
diff --git a/webui/src/components/index.js b/webui/src/components/index.js
index a7b438026..bff9ec7b4 100644
--- a/webui/src/components/index.js
+++ b/webui/src/components/index.js
@@ -19,6 +19,7 @@ import Confirm from './Shared/Confirm';
import * as Subscriber from './Subscriber';
import * as Profile from './Profile';
import * as Account from './Account';
+import * as Eir from './Eir';
export {
Layout,
@@ -41,5 +42,6 @@ export {
Subscriber,
Profile,
- Account
+ Account,
+ Eir
}
diff --git a/webui/src/containers/App.js b/webui/src/containers/App.js
index 4daa7e4bd..fe3beb82b 100644
--- a/webui/src/containers/App.js
+++ b/webui/src/containers/App.js
@@ -11,6 +11,7 @@ import Notification from 'containers/Notification';
import * as Subscriber from 'containers/Subscriber';
import * as Profile from 'containers/Profile';
import * as Account from 'containers/Account';
+import * as Eir from 'containers/Eir';
class App extends Component {
static propTypes = {
@@ -36,7 +37,7 @@ class App extends Component {
session
} = this.props;
- if (view === "subscriber") {
+ if (view === "subscriber" || view === "eir") {
document.body.style.backgroundColor = "#e9ecef";
} else {
document.body.style.backgroundColor = "white";
@@ -47,6 +48,9 @@ class App extends Component {
+
+
+
diff --git a/webui/src/containers/Eir/Collection.js b/webui/src/containers/Eir/Collection.js
new file mode 100644
index 000000000..64860befa
--- /dev/null
+++ b/webui/src/containers/Eir/Collection.js
@@ -0,0 +1,148 @@
+import { Component } from 'react';
+import { connect } from 'react-redux';
+import { MODEL, fetchEirRecords, deleteEirRecord } from 'modules/crud/eir';
+import { clearActionStatus } from 'modules/crud/actions';
+import { select, selectActionStatus } from 'modules/crud/selectors';
+import * as Notification from 'modules/notification/actions';
+import {
+ Layout,
+ Eir,
+ Spinner,
+ FloatingButton,
+ Blank,
+ Dimmed,
+ Confirm
+} from 'components';
+import Document from './Document';
+class Collection extends Component {
+ state = {
+ search: '',
+ document: { action: '', visible: false, dimmed: false },
+ confirm: { visible: false, id: '' },
+ view: { visible: false, disableOnClickOutside: false, id: '' }
+ };
+ componentWillMount() {
+ if (this.props.records.needsFetch)
+ this.props.dispatch(this.props.records.fetch);
+ }
+ componentWillReceiveProps(nextProps) {
+ const { records, status, dispatch } = nextProps;
+ if (records.needsFetch) dispatch(records.fetch);
+ if (status.response) {
+ dispatch(
+ Notification.success({
+ title: 'EIR',
+ message: `${status.id} has been deleted`
+ })
+ );
+ dispatch(clearActionStatus(MODEL, 'delete'));
+ }
+ }
+ handleSearchChange = (e) => this.setState({ search: e.target.value });
+ handleSearchClear = () => this.setState({ search: '' });
+ documentHandler = {
+ show: (action, payload) =>
+ this.setState({
+ document: { action, visible: true, dimmed: true, ...payload },
+ view: { ...this.state.view, disableOnClickOutside: true }
+ }),
+ hide: () =>
+ this.setState({
+ document: { action: '', visible: false, dimmed: false },
+ view: { ...this.state.view, disableOnClickOutside: false }
+ }),
+ actions: {
+ create: () => this.documentHandler.show('create'),
+ update: (id) => this.documentHandler.show('update', { id })
+ }
+ };
+ confirmHandler = {
+ show: (id) =>
+ this.setState({
+ confirm: { visible: true, id },
+ view: { ...this.state.view, disableOnClickOutside: true }
+ }),
+ hide: () =>
+ this.setState({
+ confirm: { ...this.state.confirm, visible: false },
+ view: { ...this.state.view, disableOnClickOutside: false }
+ }),
+ actions: {
+ delete: () => {
+ if (this.state.confirm.visible) {
+ const id = this.state.confirm.id;
+ this.confirmHandler.hide();
+ this.documentHandler.hide();
+ this.viewHandler.hide();
+ this.props.dispatch(deleteEirRecord(id));
+ }
+ }
+ }
+ };
+ viewHandler = {
+ show: (id) =>
+ this.setState({
+ view: { id, visible: true, disableOnClickOutside: false }
+ }),
+ hide: () => this.setState({ view: { ...this.state.view, visible: false } })
+ };
+ render() {
+ const { search, document, view } = this.state;
+ const { records, status } = this.props;
+ const data = records.data;
+ return (
+
+ {data.length > 0 && (
+
+ )}
+
+ {records.isLoading && }
+
+
+ record._id === view.id)[0]}
+ disableOnClickOutside={view.disableOnClickOutside}
+ onEdit={this.documentHandler.actions.update}
+ onDelete={this.confirmHandler.show}
+ onHide={this.viewHandler.hide}
+ />
+
+
+
+
+ );
+ }
+}
+export default connect((state) => ({
+ records: select(fetchEirRecords(), state.crud),
+ status: selectActionStatus(MODEL, state.crud, 'delete')
+}))(Collection);
diff --git a/webui/src/containers/Eir/Document.js b/webui/src/containers/Eir/Document.js
new file mode 100644
index 000000000..1cc098344
--- /dev/null
+++ b/webui/src/containers/Eir/Document.js
@@ -0,0 +1,126 @@
+import { Component } from 'react';
+import { connect } from 'react-redux';
+import NProgress from 'nprogress';
+import {
+ MODEL,
+ fetchEirRecords,
+ fetchEirRecord,
+ createEirRecord,
+ updateEirRecord
+} from 'modules/crud/eir';
+import { clearActionStatus } from 'modules/crud/actions';
+import { select, selectActionStatus } from 'modules/crud/selectors';
+import * as Notification from 'modules/notification/actions';
+import { Eir } from 'components';
+const empty = { pei_type: 'imei', pei: '', imsi: '', status: 'WHITELISTED' };
+const decode = (r) => {
+ if (!r) return empty;
+ const p = (r.pei || '').match(/^(imei|imeisv)-(\d+)$/) || [];
+ const s = (r.supi || '').match(/^imsi-(\d+)$/) || [];
+ return {
+ _id: r._id,
+ pei_type: p[1] || 'imei',
+ pei: p[2] || '',
+ imsi: s[1] || '',
+ status: r.status
+ };
+};
+const encode = (f) => {
+ const data = { pei: `${f.pei_type}-${f.pei}`, status: f.status };
+ if (f.imsi) data.supi = `imsi-${f.imsi}`;
+ return data;
+};
+class Document extends Component {
+ state = { formData: empty };
+ componentWillMount() {
+ if (this.props.record.needsFetch)
+ this.props.dispatch(this.props.record.fetch);
+ }
+ componentWillReceiveProps(p) {
+ if (p.record.needsFetch) p.dispatch(p.record.fetch);
+ this.setState({ formData: decode(p.record.data) });
+ if (p.status.response) {
+ NProgress.done(true);
+ p.dispatch(
+ Notification.success({
+ title: 'EIR',
+ message:
+ p.action === 'create'
+ ? 'New EIR rule created'
+ : `${p.status.id} EIR rule updated`
+ })
+ );
+ p.dispatch(clearActionStatus(MODEL, p.action));
+ p.onHide();
+ }
+ if (p.status.error) {
+ NProgress.done(true);
+ const response = (p.status.error || {}).response || {};
+ p.dispatch(
+ Notification.error({
+ title: (response.data || {}).name || response.status || 'EIR Error',
+ message:
+ (response.data || {}).message ||
+ response.statusText ||
+ 'Unable to save EIR rule',
+ autoDismiss: 0,
+ action: { label: 'Dismiss' }
+ })
+ );
+ p.dispatch(clearActionStatus(MODEL, p.action));
+ }
+ }
+ validate = (f, e) => {
+ const length = f.pei_type === 'imeisv' ? 16 : 15;
+ if (f.pei && f.pei.length !== length)
+ e.pei.addError(
+ `${f.pei_type.toUpperCase()} must contain ${length} digits`
+ );
+ if (f.imsi && (f.imsi.length < 6 || f.imsi.length > 15))
+ e.imsi.addError('IMSI must contain 6 to 15 digits');
+ if (this.props.action === 'create') {
+ const data = encode(f);
+ const records = this.props.records.data;
+ if (records.some((r) =>
+ r.pei === data.pei && (r.supi || '') === (data.supi || '')
+ )) {
+ e.pei.addError('This equipment identity rule already exists');
+ }
+ }
+ return e;
+ };
+ submit = (f) => {
+ NProgress.configure({ parent: '#nprogress-base-form', trickleSpeed: 5 });
+ NProgress.start();
+ const data = encode(f);
+ if (this.props.action === 'create')
+ this.props.dispatch(createEirRecord({}, data));
+ else this.props.dispatch(updateEirRecord(f._id, {}, data));
+ };
+ handleError = (errors) =>
+ errors.map((error) =>
+ this.props.dispatch(
+ Notification.error({ title: 'Validation Error', message: error.stack })
+ )
+ );
+ render() {
+ const p = this.props;
+ return (
+
+ );
+ }
+}
+export default connect((state, props) => ({
+ records: select(fetchEirRecords(), state.crud),
+ record: select(fetchEirRecord(props.id), state.crud),
+ status: selectActionStatus(MODEL, state.crud, props.action)
+}))(Document);
diff --git a/webui/src/containers/Eir/index.js b/webui/src/containers/Eir/index.js
new file mode 100644
index 000000000..e0c172253
--- /dev/null
+++ b/webui/src/containers/Eir/index.js
@@ -0,0 +1,3 @@
+import Collection from './Collection';
+import Document from './Document';
+export { Collection, Document };
diff --git a/webui/src/modules/crud/eir.js b/webui/src/modules/crud/eir.js
new file mode 100644
index 000000000..9b35720a3
--- /dev/null
+++ b/webui/src/modules/crud/eir.js
@@ -0,0 +1,19 @@
+import {
+ fetchCollection,
+ fetchDocument,
+ createDocument,
+ updateDocument,
+ deleteDocument
+} from './actions';
+export const MODEL = 'eir';
+export const URL = '/Eir';
+export const fetchEirRecords = (params = {}) =>
+ fetchCollection(MODEL, URL, params);
+export const fetchEirRecord = (id, params = {}) =>
+ fetchDocument(MODEL, id, `${URL}/${id}`, params);
+export const createEirRecord = (params = {}, data = {}) =>
+ createDocument(MODEL, URL, params, data);
+export const updateEirRecord = (id, params = {}, data = {}) =>
+ updateDocument(MODEL, id, `${URL}/${id}`, params, data);
+export const deleteEirRecord = (id, params = {}) =>
+ deleteDocument(MODEL, id, `${URL}/${id}`, params);