mirror of
https://github.com/chirpstack/chirpstack.git
synced 2025-06-16 06:18:27 +00:00
Initial commit.
This commit is contained in:
82
ui/src/components/Admin.tsx
Normal file
82
ui/src/components/Admin.tsx
Normal file
@ -0,0 +1,82 @@
|
||||
import { Component } from "react";
|
||||
|
||||
import SessionStore from "../stores/SessionStore";
|
||||
|
||||
|
||||
interface IProps {
|
||||
tenantId?: string;
|
||||
isDeviceAdmin?: boolean;
|
||||
isGatewayAdmin?: boolean;
|
||||
isTenantAdmin?: boolean;
|
||||
}
|
||||
|
||||
interface IState {
|
||||
admin: boolean;
|
||||
}
|
||||
|
||||
class Admin extends Component<IProps, IState> {
|
||||
constructor(props: IProps) {
|
||||
super(props);
|
||||
|
||||
this.state = {
|
||||
admin: false,
|
||||
};
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
SessionStore.on("change", this.setIsAdmin);
|
||||
this.setIsAdmin();
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
SessionStore.removeListener("change", this.setIsAdmin);
|
||||
}
|
||||
|
||||
componentDidUpdate(prevProps: IProps) {
|
||||
if (prevProps === this.props) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.setIsAdmin();
|
||||
}
|
||||
|
||||
setIsAdmin = () => {
|
||||
if (!this.props.isDeviceAdmin && !this.props.isGatewayAdmin && !this.props.isTenantAdmin) {
|
||||
this.setState({
|
||||
admin: SessionStore.isAdmin(),
|
||||
});
|
||||
} else {
|
||||
if (this.props.tenantId === undefined) {
|
||||
throw new Error("No tenantId is given");
|
||||
}
|
||||
|
||||
if (this.props.isTenantAdmin) {
|
||||
this.setState({
|
||||
admin: SessionStore.isAdmin() || SessionStore.isTenantAdmin(this.props.tenantId),
|
||||
});
|
||||
}
|
||||
|
||||
if (this.props.isDeviceAdmin) {
|
||||
this.setState({
|
||||
admin: SessionStore.isAdmin() || SessionStore.isTenantDeviceAdmin(this.props.tenantId),
|
||||
});
|
||||
}
|
||||
|
||||
if (this.props.isGatewayAdmin) {
|
||||
this.setState({
|
||||
admin: SessionStore.isAdmin() || SessionStore.isTenantGatewayAdmin(this.props.tenantId),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
render() {
|
||||
if (this.state.admin) {
|
||||
return(this.props.children);
|
||||
}
|
||||
|
||||
return(null);
|
||||
}
|
||||
}
|
||||
|
||||
export default Admin;
|
127
ui/src/components/AesKeyInput.tsx
Normal file
127
ui/src/components/AesKeyInput.tsx
Normal file
@ -0,0 +1,127 @@
|
||||
import React, { Component } from "react";
|
||||
|
||||
import { Input, Select, Button, Space, Form } from "antd";
|
||||
import { ReloadOutlined } from "@ant-design/icons";
|
||||
|
||||
interface IProps {
|
||||
formRef: React.RefObject<any>,
|
||||
label: string,
|
||||
name: string,
|
||||
required?: boolean;
|
||||
value?: string;
|
||||
disabled?: boolean;
|
||||
tooltip?: string;
|
||||
}
|
||||
|
||||
interface IState {
|
||||
byteOrder: string;
|
||||
value: string;
|
||||
}
|
||||
|
||||
|
||||
class AesKeyInput extends Component<IProps, IState> {
|
||||
constructor(props: IProps) {
|
||||
super(props);
|
||||
this.state = {
|
||||
byteOrder: "msb",
|
||||
value: "",
|
||||
};
|
||||
}
|
||||
|
||||
updateField = () => {
|
||||
let value = this.state.value;
|
||||
|
||||
if (this.state.byteOrder === "lsb") {
|
||||
const bytes = value.match(/[A-Fa-f0-9]{2}/g) || [];
|
||||
value = bytes.reverse().join("");
|
||||
}
|
||||
|
||||
this.props.formRef.current.setFieldsValue({
|
||||
[this.props.name]: value,
|
||||
});
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
if (this.props.value) {
|
||||
this.setState({
|
||||
value: this.props.value,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
onChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
let v = e.target.value;
|
||||
const match = v.match(/[A-Fa-f0-9]/g);
|
||||
|
||||
let value = "";
|
||||
if (match) {
|
||||
if (match.length > 32) {
|
||||
value = match.slice(0, 32).join("");
|
||||
} else {
|
||||
value = match.join("");
|
||||
}
|
||||
}
|
||||
|
||||
this.setState({
|
||||
value: value,
|
||||
}, this.updateField);
|
||||
}
|
||||
|
||||
onByteOrderSelect = (v: string) => {
|
||||
if (v === this.state.byteOrder) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.setState({
|
||||
byteOrder: v,
|
||||
});
|
||||
|
||||
const current = this.state.value;
|
||||
const bytes = current.match(/[A-Fa-f0-9]{2}/g) || [];
|
||||
|
||||
this.setState({
|
||||
value: bytes.reverse().join(""),
|
||||
}, this.updateField);
|
||||
}
|
||||
|
||||
generateRandom = () => {
|
||||
let cryptoObj = window.crypto || window.Crypto;
|
||||
let b = new Uint8Array(16);
|
||||
cryptoObj.getRandomValues(b);
|
||||
|
||||
let key = Buffer.from(b).toString('hex');
|
||||
this.setState({
|
||||
value: key,
|
||||
}, this.updateField);
|
||||
}
|
||||
|
||||
render() {
|
||||
const addon = (
|
||||
<Space size="large">
|
||||
<Select value={this.state.byteOrder} onChange={this.onByteOrderSelect}>
|
||||
<Select.Option value="msb">MSB</Select.Option>
|
||||
<Select.Option value="lsb">LSB</Select.Option>
|
||||
</Select>
|
||||
<Button type="text" size="small" shape="circle" onClick={this.generateRandom}><ReloadOutlined /></Button>
|
||||
</Space>
|
||||
);
|
||||
|
||||
return(
|
||||
<Form.Item
|
||||
rules={[{
|
||||
required: this.props.required,
|
||||
message: `Please enter a valid ${this.props.label}`,
|
||||
pattern: new RegExp(/[A-Fa-f0-9]{32}/g),
|
||||
}]}
|
||||
label={this.props.label}
|
||||
name={this.props.name}
|
||||
tooltip={this.props.tooltip}
|
||||
>
|
||||
<Input hidden />
|
||||
<Input.Password id={`${this.props.name}Render`} onChange={this.onChange} addonAfter={!this.props.disabled && addon} style={{fontFamily: "monospace"}} value={this.state.value} disabled={this.props.disabled} />
|
||||
</Form.Item>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default AesKeyInput;
|
107
ui/src/components/Autocomplete.tsx
Normal file
107
ui/src/components/Autocomplete.tsx
Normal file
@ -0,0 +1,107 @@
|
||||
import React, { Component } from "react";
|
||||
|
||||
import { Select } from "antd";
|
||||
|
||||
export type OptionsCallbackFunc = (o: {label: string, value: string}[]) => void;
|
||||
export type OptionCallbackFunc = (o: {label: string, value: string}) => void;
|
||||
|
||||
interface IProps {
|
||||
placeholder: string;
|
||||
className: string;
|
||||
value?: string,
|
||||
getOption: (s: string, fn: OptionCallbackFunc) => void,
|
||||
getOptions: (s: string, fn: OptionsCallbackFunc) => void,
|
||||
onSelect?: (s: string) => void,
|
||||
}
|
||||
|
||||
interface IState {
|
||||
option?: {label: string, value: string};
|
||||
options: {label: string, value: string}[];
|
||||
}
|
||||
|
||||
|
||||
class Autocomplete extends Component<IProps, IState> {
|
||||
constructor(props: IProps) {
|
||||
super(props);
|
||||
|
||||
this.state = {
|
||||
options: [],
|
||||
};
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
if (this.props.value && this.props.value !== "") {
|
||||
this.props.getOption(this.props.value, (o: {label: string, value: string}) => {
|
||||
this.setState({
|
||||
options: [o],
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
componentDidUpdate(prevProps: IProps) {
|
||||
if (this.props.value === prevProps.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.props.value && this.props.value !== "") {
|
||||
this.props.getOption(this.props.value, (o: {label: string, value: string}) => {
|
||||
this.setState({
|
||||
options: [o],
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
onFocus = () => {
|
||||
this.props.getOptions("", (options) => {
|
||||
if (this.state.option !== undefined) {
|
||||
const selected = this.state.option.value;
|
||||
|
||||
if (options.find(e => e.value === selected) === undefined) {
|
||||
options.unshift(this.state.option);
|
||||
}
|
||||
}
|
||||
|
||||
this.setState({
|
||||
options: options,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
onSearch = (value: string) => {
|
||||
this.props.getOptions(value, (options) => {
|
||||
this.setState({
|
||||
options: options,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
onSelect = (value: string, option: any) => {
|
||||
this.setState({
|
||||
option: {label: option.label, value: option.value},
|
||||
});
|
||||
|
||||
if (this.props.onSelect !== undefined) {
|
||||
this.props.onSelect(value);
|
||||
}
|
||||
}
|
||||
|
||||
render() {
|
||||
const { getOption, getOptions, ...otherProps } = this.props;
|
||||
|
||||
return(
|
||||
<Select
|
||||
showSearch
|
||||
options={this.state.options}
|
||||
onFocus={this.onFocus}
|
||||
onSearch={this.onSearch}
|
||||
onSelect={this.onSelect}
|
||||
filterOption={false}
|
||||
{...otherProps}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default Autocomplete;
|
41
ui/src/components/AutocompleteInput.tsx
Normal file
41
ui/src/components/AutocompleteInput.tsx
Normal file
@ -0,0 +1,41 @@
|
||||
import React, { Component } from "react";
|
||||
|
||||
import { Form } from "antd";
|
||||
|
||||
import Autocomplete, { OptionCallbackFunc, OptionsCallbackFunc } from "./Autocomplete";
|
||||
|
||||
|
||||
interface IProps {
|
||||
formRef: React.RefObject<any>,
|
||||
label: string,
|
||||
name: string,
|
||||
required?: boolean;
|
||||
value?: string;
|
||||
getOption: (s: string, fn: OptionCallbackFunc) => void,
|
||||
getOptions: (s: string, fn: OptionsCallbackFunc) => void,
|
||||
}
|
||||
|
||||
|
||||
class AutocompleteInput extends Component<IProps> {
|
||||
render() {
|
||||
return(
|
||||
<Form.Item
|
||||
rules={[{
|
||||
required: this.props.required,
|
||||
message: `Please select a ${this.props.label}`,
|
||||
}]}
|
||||
label={this.props.label}
|
||||
name={this.props.name}
|
||||
>
|
||||
<Autocomplete
|
||||
placeholder={`Select a ${this.props.label}`}
|
||||
className=""
|
||||
getOption={this.props.getOption}
|
||||
getOptions={this.props.getOptions}
|
||||
/>
|
||||
</Form.Item>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default AutocompleteInput;
|
80
ui/src/components/CodeEditor.tsx
Normal file
80
ui/src/components/CodeEditor.tsx
Normal file
@ -0,0 +1,80 @@
|
||||
import React, { Component } from "react";
|
||||
import {Controlled as CodeMirror} from "react-codemirror2";
|
||||
|
||||
import { Form } from "antd";
|
||||
|
||||
import "codemirror/mode/javascript/javascript";
|
||||
|
||||
|
||||
interface IProps {
|
||||
formRef: React.RefObject<any>,
|
||||
label?: string,
|
||||
name: string,
|
||||
required?: boolean;
|
||||
value?: string;
|
||||
disabled?: boolean;
|
||||
tooltip?: string;
|
||||
}
|
||||
|
||||
interface IState {
|
||||
value: string;
|
||||
}
|
||||
|
||||
|
||||
class CodeEditor extends Component<IProps, IState> {
|
||||
constructor(props: IProps) {
|
||||
super(props);
|
||||
this.state = {
|
||||
value: "",
|
||||
};
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
if (this.props.value) {
|
||||
this.setState({
|
||||
value: this.props.value,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
updateField = () => {
|
||||
let value = this.state.value;
|
||||
|
||||
this.props.formRef.current.setFieldsValue({
|
||||
[this.props.name]: value,
|
||||
});
|
||||
}
|
||||
|
||||
handleChange = (editor: any, data: any, newCode: string) => {
|
||||
this.setState({
|
||||
value: newCode,
|
||||
}, this.updateField);
|
||||
}
|
||||
|
||||
render() {
|
||||
const codeMirrorOptions = {
|
||||
lineNumbers: true,
|
||||
mode: "javascript",
|
||||
theme: "base16-light",
|
||||
readOnly: this.props.disabled,
|
||||
};
|
||||
|
||||
return (
|
||||
<Form.Item
|
||||
label={this.props.label}
|
||||
name={this.props.name}
|
||||
tooltip={this.props.tooltip}
|
||||
>
|
||||
<div style={{border: "1px solid #cccccc"}}>
|
||||
<CodeMirror
|
||||
value={this.state.value}
|
||||
options={codeMirrorOptions}
|
||||
onBeforeChange={this.handleChange}
|
||||
/>
|
||||
</div>
|
||||
</Form.Item>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default CodeEditor
|
129
ui/src/components/DataTable.tsx
Normal file
129
ui/src/components/DataTable.tsx
Normal file
@ -0,0 +1,129 @@
|
||||
import React, { Component } from "react";
|
||||
|
||||
import { Table } from "antd";
|
||||
import { ColumnsType } from "antd/es/table";
|
||||
|
||||
import SessionStore from "../stores/SessionStore";
|
||||
|
||||
|
||||
export type GetPageCallbackFunc = (totalCount: number, rows: object[]) => void;
|
||||
|
||||
|
||||
interface IProps {
|
||||
columns: ColumnsType<any>;
|
||||
getPage: (limit: number, offset: number, callbackFunc: GetPageCallbackFunc) => void;
|
||||
onRowsSelectChange?: (ids: string[]) => void;
|
||||
rowKey: string;
|
||||
refreshKey?: any;
|
||||
noPagination?: boolean;
|
||||
}
|
||||
|
||||
interface IState {
|
||||
totalCount: number;
|
||||
pageSize: number;
|
||||
currentPage: number;
|
||||
rows: object[];
|
||||
loading: boolean,
|
||||
}
|
||||
|
||||
|
||||
class DataTable extends Component<IProps, IState> {
|
||||
constructor(props: IProps) {
|
||||
super(props);
|
||||
|
||||
this.state = {
|
||||
totalCount: 0,
|
||||
pageSize: SessionStore.getRowsPerPage(),
|
||||
currentPage: 1,
|
||||
rows: [],
|
||||
loading: true,
|
||||
};
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
this.onChangePage(this.state.currentPage, this.state.pageSize);
|
||||
}
|
||||
|
||||
componentDidUpdate(prevProps: IProps) {
|
||||
if (this.props === prevProps) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.onChangePage(this.state.currentPage, this.state.pageSize);
|
||||
}
|
||||
|
||||
onChangePage = (page: number, pageSize?: number | void) => {
|
||||
this.setState({
|
||||
loading: true,
|
||||
}, () => {
|
||||
let pz = pageSize;
|
||||
if (!pz) {
|
||||
pz = this.state.pageSize;
|
||||
}
|
||||
|
||||
this.props.getPage(pz, (page - 1) * pz, (totalCount: number, rows: object[]) => {
|
||||
this.setState({
|
||||
currentPage: page,
|
||||
totalCount: totalCount,
|
||||
rows: rows,
|
||||
pageSize: pz || 0,
|
||||
loading: false,
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
onShowSizeChange = (page: number, pageSize: number) => {
|
||||
this.onChangePage(page, pageSize);
|
||||
SessionStore.setRowsPerPage(pageSize);
|
||||
}
|
||||
|
||||
onRowsSelectChange = (ids: React.Key[]) => {
|
||||
const idss = ids as string[];
|
||||
if (this.props.onRowsSelectChange) {
|
||||
this.props.onRowsSelectChange(idss);
|
||||
}
|
||||
}
|
||||
|
||||
render() {
|
||||
const { getPage, refreshKey, ...otherProps } = this.props;
|
||||
let loadingProps = undefined;
|
||||
if (this.state.loading) {
|
||||
loadingProps = {
|
||||
delay: 300,
|
||||
};
|
||||
}
|
||||
|
||||
let pagination = undefined;
|
||||
if (this.props.noPagination === undefined || this.props.noPagination === false) {
|
||||
pagination = {
|
||||
current: this.state.currentPage,
|
||||
total: this.state.totalCount,
|
||||
pageSize: this.state.pageSize,
|
||||
onChange: this.onChangePage,
|
||||
showSizeChanger: true,
|
||||
onShowSizeChange: this.onShowSizeChange,
|
||||
};
|
||||
}
|
||||
|
||||
let rowSelection = undefined;
|
||||
if (this.props.onRowsSelectChange) {
|
||||
rowSelection = {
|
||||
onChange: this.onRowsSelectChange,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
return(
|
||||
<Table
|
||||
loading={loadingProps}
|
||||
dataSource={this.state.rows}
|
||||
pagination={pagination || false}
|
||||
rowSelection={rowSelection}
|
||||
{...otherProps}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default DataTable;
|
54
ui/src/components/DeleteConfirm.tsx
Normal file
54
ui/src/components/DeleteConfirm.tsx
Normal file
@ -0,0 +1,54 @@
|
||||
import React, { Component } from "react";
|
||||
|
||||
import { Popover, Button, Typography, Space, Input } from 'antd';
|
||||
|
||||
|
||||
interface IProps {
|
||||
typ: string;
|
||||
confirm: string;
|
||||
onConfirm: () => void;
|
||||
}
|
||||
|
||||
|
||||
interface ConfirmState {
|
||||
confirm: string;
|
||||
}
|
||||
|
||||
|
||||
class DeleteConfirmContent extends Component<IProps, ConfirmState> {
|
||||
constructor(props: IProps) {
|
||||
super(props);
|
||||
this.state = {
|
||||
confirm: '',
|
||||
};
|
||||
}
|
||||
|
||||
onChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
this.setState({
|
||||
confirm: e.target.value,
|
||||
});
|
||||
}
|
||||
|
||||
render() {
|
||||
return(
|
||||
<Space direction="vertical">
|
||||
<Typography.Text>Enter '{this.props.confirm}' to confirm you want to delete this {this.props.typ}:</Typography.Text>
|
||||
<Input placeholder={this.props.confirm} onChange={this.onChange} />
|
||||
<Button onClick={this.props.onConfirm} disabled={this.state.confirm !== this.props.confirm} style={{float: "right"}}>Delete</Button>
|
||||
</Space>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
class DeleteConfirm extends Component<IProps> {
|
||||
render() {
|
||||
return(
|
||||
<Popover content={<DeleteConfirmContent {...this.props}/>} trigger="click" placement="left">
|
||||
{this.props.children}
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default DeleteConfirm;
|
136
ui/src/components/DevAddrInput.tsx
Normal file
136
ui/src/components/DevAddrInput.tsx
Normal file
@ -0,0 +1,136 @@
|
||||
import React, { Component } from "react";
|
||||
|
||||
import { Input, Select, Button, Space, Form } from "antd";
|
||||
import { ReloadOutlined } from "@ant-design/icons";
|
||||
|
||||
|
||||
import {
|
||||
GetRandomDevAddrRequest,
|
||||
GetRandomDevAddrResponse,
|
||||
} from "@chirpstack/chirpstack-api-grpc-web/api/device_pb";
|
||||
|
||||
|
||||
import DeviceStore from "../stores/DeviceStore";
|
||||
|
||||
|
||||
interface IProps {
|
||||
formRef: React.RefObject<any>,
|
||||
label: string,
|
||||
name: string,
|
||||
devEui: string,
|
||||
required?: boolean;
|
||||
value?: string;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
interface IState {
|
||||
byteOrder: string;
|
||||
value: string;
|
||||
}
|
||||
|
||||
|
||||
class DevAddrInput extends Component<IProps, IState> {
|
||||
constructor(props: IProps) {
|
||||
super(props);
|
||||
this.state = {
|
||||
byteOrder: "msb",
|
||||
value: "",
|
||||
};
|
||||
}
|
||||
|
||||
updateField = () => {
|
||||
let value = this.state.value;
|
||||
|
||||
if (this.state.byteOrder === "lsb") {
|
||||
const bytes = value.match(/[A-Fa-f0-9]{2}/g) || [];
|
||||
value = bytes.reverse().join("");
|
||||
}
|
||||
|
||||
this.props.formRef.current.setFieldsValue({
|
||||
[this.props.name]: value,
|
||||
});
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
if (this.props.value) {
|
||||
this.setState({
|
||||
value: this.props.value,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
onChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
let v = e.target.value;
|
||||
const match = v.match(/[A-Fa-f0-9]/g);
|
||||
|
||||
let value = "";
|
||||
if (match) {
|
||||
if (match.length > 8) {
|
||||
value = match.slice(0, 8).join("");
|
||||
} else {
|
||||
value = match.join("");
|
||||
}
|
||||
}
|
||||
|
||||
this.setState({
|
||||
value: value,
|
||||
}, this.updateField);
|
||||
}
|
||||
|
||||
onByteOrderSelect = (v: string) => {
|
||||
if (v === this.state.byteOrder) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.setState({
|
||||
byteOrder: v,
|
||||
});
|
||||
|
||||
const current = this.state.value;
|
||||
const bytes = current.match(/[A-Fa-f0-9]{2}/g) || [];
|
||||
|
||||
this.setState({
|
||||
value: bytes.reverse().join(""),
|
||||
}, this.updateField);
|
||||
}
|
||||
|
||||
generateRandom = () => {
|
||||
let req = new GetRandomDevAddrRequest();
|
||||
req.setDevEui(this.props.devEui);
|
||||
|
||||
DeviceStore.getRandomDevAddr(req, (resp: GetRandomDevAddrResponse) => {
|
||||
this.setState({
|
||||
value: resp.getDevAddr(),
|
||||
}, this.updateField);
|
||||
});
|
||||
}
|
||||
|
||||
render() {
|
||||
const addon = (
|
||||
<Space size="large">
|
||||
<Select value={this.state.byteOrder} onChange={this.onByteOrderSelect}>
|
||||
<Select.Option value="msb">MSB</Select.Option>
|
||||
<Select.Option value="lsb">LSB</Select.Option>
|
||||
</Select>
|
||||
<Button type="text" size="small" shape="circle" onClick={this.generateRandom}><ReloadOutlined /></Button>
|
||||
</Space>
|
||||
);
|
||||
|
||||
return(
|
||||
<Form.Item
|
||||
rules={[{
|
||||
required: this.props.required,
|
||||
message: `Please enter a valid ${this.props.label}`,
|
||||
pattern: new RegExp(/[A-Fa-f0-9]{8}/g),
|
||||
}]}
|
||||
label={this.props.label}
|
||||
name={this.props.name}
|
||||
>
|
||||
<Input hidden />
|
||||
<Input id={`${this.props.name}Render`} onChange={this.onChange} addonAfter={!this.props.disabled && addon} style={{fontFamily: "monospace"}} value={this.state.value} disabled={this.props.disabled} />
|
||||
</Form.Item>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default DevAddrInput;
|
125
ui/src/components/EuiInput.tsx
Normal file
125
ui/src/components/EuiInput.tsx
Normal file
@ -0,0 +1,125 @@
|
||||
import React, { Component } from "react";
|
||||
|
||||
import { Input, Select, Button, Space, Form } from "antd";
|
||||
import { ReloadOutlined } from "@ant-design/icons";
|
||||
|
||||
interface IProps {
|
||||
formRef: React.RefObject<any>,
|
||||
label: string,
|
||||
name: string,
|
||||
required?: boolean;
|
||||
value?: string;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
interface IState {
|
||||
byteOrder: string;
|
||||
value: string;
|
||||
}
|
||||
|
||||
|
||||
class EuiInput extends Component<IProps, IState> {
|
||||
constructor(props: IProps) {
|
||||
super(props);
|
||||
this.state = {
|
||||
byteOrder: "msb",
|
||||
value: "",
|
||||
};
|
||||
}
|
||||
|
||||
updateField = () => {
|
||||
let value = this.state.value;
|
||||
|
||||
if (this.state.byteOrder === "lsb") {
|
||||
const bytes = value.match(/[A-Fa-f0-9]{2}/g) || [];
|
||||
value = bytes.reverse().join("");
|
||||
}
|
||||
|
||||
this.props.formRef.current.setFieldsValue({
|
||||
[this.props.name]: value,
|
||||
});
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
if (this.props.value) {
|
||||
this.setState({
|
||||
value: this.props.value,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
onChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
let v = e.target.value;
|
||||
const match = v.match(/[A-Fa-f0-9]/g);
|
||||
|
||||
let value = "";
|
||||
if (match) {
|
||||
if (match.length > 16) {
|
||||
value = match.slice(0, 16).join("");
|
||||
} else {
|
||||
value = match.join("");
|
||||
}
|
||||
}
|
||||
|
||||
this.setState({
|
||||
value: value,
|
||||
}, this.updateField);
|
||||
}
|
||||
|
||||
onByteOrderSelect = (v: string) => {
|
||||
if (v === this.state.byteOrder) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.setState({
|
||||
byteOrder: v,
|
||||
});
|
||||
|
||||
const current = this.state.value;
|
||||
const bytes = current.match(/[A-Fa-f0-9]{2}/g) || [];
|
||||
|
||||
this.setState({
|
||||
value: bytes.reverse().join(""),
|
||||
}, this.updateField);
|
||||
}
|
||||
|
||||
generateRandom = () => {
|
||||
let cryptoObj = window.crypto || window.Crypto;
|
||||
let b = new Uint8Array(8);
|
||||
cryptoObj.getRandomValues(b);
|
||||
|
||||
let key = Buffer.from(b).toString('hex');
|
||||
this.setState({
|
||||
value: key,
|
||||
}, this.updateField);
|
||||
}
|
||||
|
||||
render() {
|
||||
const addon = (
|
||||
<Space size="large">
|
||||
<Select value={this.state.byteOrder} onChange={this.onByteOrderSelect}>
|
||||
<Select.Option value="msb">MSB</Select.Option>
|
||||
<Select.Option value="lsb">LSB</Select.Option>
|
||||
</Select>
|
||||
<Button type="text" size="small" shape="circle" onClick={this.generateRandom}><ReloadOutlined /></Button>
|
||||
</Space>
|
||||
);
|
||||
|
||||
return(
|
||||
<Form.Item
|
||||
rules={[{
|
||||
required: this.props.required,
|
||||
message: `Please enter a valid ${this.props.label}`,
|
||||
pattern: new RegExp(/[A-Fa-f0-9]{16}/g),
|
||||
}]}
|
||||
label={this.props.label}
|
||||
name={this.props.name}
|
||||
>
|
||||
<Input hidden />
|
||||
<Input id={`${this.props.name}Render`} onChange={this.onChange} addonAfter={!this.props.disabled && addon} style={{fontFamily: "monospace"}} value={this.state.value} disabled={this.props.disabled} />
|
||||
</Form.Item>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default EuiInput;
|
158
ui/src/components/Header.tsx
Normal file
158
ui/src/components/Header.tsx
Normal file
@ -0,0 +1,158 @@
|
||||
import React, { Component } from "react";
|
||||
import { Link } from "react-router-dom";
|
||||
|
||||
import { Button, Menu, Dropdown, Input, AutoComplete } from "antd";
|
||||
import { UserOutlined, DownOutlined, QuestionOutlined } from "@ant-design/icons";
|
||||
|
||||
import { User } from "@chirpstack/chirpstack-api-grpc-web/api/user_pb";
|
||||
import {
|
||||
SettingsResponse,
|
||||
GlobalSearchRequest,
|
||||
GlobalSearchResponse,
|
||||
} from "@chirpstack/chirpstack-api-grpc-web/api/internal_pb";
|
||||
|
||||
import InternalStore from "../stores/InternalStore";
|
||||
|
||||
|
||||
interface IProps {
|
||||
user: User;
|
||||
}
|
||||
|
||||
interface IState {
|
||||
searchResult?: GlobalSearchResponse;
|
||||
settings?: SettingsResponse,
|
||||
}
|
||||
|
||||
const renderTitle = (title: string) => (
|
||||
<span>
|
||||
{title}
|
||||
</span>
|
||||
);
|
||||
|
||||
const renderItem = (title: string, url: string) => ({
|
||||
value: title,
|
||||
label: (
|
||||
<Link to={url}>{title}</Link>
|
||||
),
|
||||
});
|
||||
|
||||
class Header extends Component<IProps, IState> {
|
||||
constructor(props: IProps) {
|
||||
super(props);
|
||||
|
||||
this.state = {};
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
InternalStore.settings((resp: SettingsResponse) => {
|
||||
this.setState({
|
||||
settings: resp,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
onSearch = (search: string) => {
|
||||
if (search.length < 3) {
|
||||
return;
|
||||
}
|
||||
|
||||
let req = new GlobalSearchRequest();
|
||||
req.setLimit(20);
|
||||
req.setSearch(search);
|
||||
|
||||
InternalStore.globalSearch(req, (resp: GlobalSearchResponse) => {
|
||||
this.setState({
|
||||
searchResult: resp,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
render() {
|
||||
if (this.state.settings === undefined) {
|
||||
return null;
|
||||
}
|
||||
|
||||
let oidcEnabled = this.state.settings!.getOpenidConnect()!.getEnabled();
|
||||
|
||||
const menu = (
|
||||
<Menu>
|
||||
{!oidcEnabled && <Menu.Item>
|
||||
<Link to={`/users/${this.props.user.getId()}/password`}>Change password</Link>
|
||||
</Menu.Item>}
|
||||
<Menu.Item>
|
||||
<Link to="/login">Logout</Link>
|
||||
</Menu.Item>
|
||||
</Menu>
|
||||
);
|
||||
|
||||
let options: {
|
||||
label: any;
|
||||
options: any[];
|
||||
}[] = [
|
||||
{
|
||||
label: renderTitle('Tenants'),
|
||||
options: [],
|
||||
},
|
||||
{
|
||||
label: renderTitle('Gateways'),
|
||||
options: [],
|
||||
},
|
||||
{
|
||||
label: renderTitle('Applications'),
|
||||
options: [],
|
||||
},
|
||||
{
|
||||
label: renderTitle('Devices'),
|
||||
options: [],
|
||||
},
|
||||
];
|
||||
|
||||
if (this.state.searchResult !== undefined) {
|
||||
for (const res of this.state.searchResult.getResultList()) {
|
||||
if (res.getKind() === "tenant") {
|
||||
options[0].options.push(renderItem(res.getTenantName(), `/tenants/${res.getTenantId()}`))
|
||||
}
|
||||
|
||||
if (res.getKind() === "gateway") {
|
||||
options[1].options.push(renderItem(res.getGatewayName(), `/tenants/${res.getTenantId()}/gateways/${res.getGatewayId()}`))
|
||||
}
|
||||
|
||||
if (res.getKind() === "application") {
|
||||
options[2].options.push(renderItem(res.getApplicationName(), `/tenants/${res.getTenantId()}/applications/${res.getApplicationId()}`))
|
||||
}
|
||||
|
||||
if (res.getKind() === "device") {
|
||||
options[3].options.push(renderItem(res.getDeviceName(), `/tenants/${res.getTenantId()}/applications/${res.getApplicationId()}/devices/${res.getDeviceDevEui()}`));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return(
|
||||
<div>
|
||||
<img className="logo" alt="ChirpStack" src="/logo.png" />
|
||||
<div className="actions">
|
||||
<div className="search">
|
||||
<AutoComplete
|
||||
dropdownClassName="search-dropdown"
|
||||
dropdownMatchSelectWidth={500}
|
||||
options={options}
|
||||
onSearch={this.onSearch}
|
||||
>
|
||||
<Input.Search placeholder="Search..." style={{width: 500, marginTop: -5}} />
|
||||
</AutoComplete>
|
||||
</div>
|
||||
<div className="help">
|
||||
<a href="https://www.chirpstack.io" target="_blank" rel="noreferrer"><Button icon={<QuestionOutlined />}/></a>
|
||||
</div>
|
||||
<div className="user">
|
||||
<Dropdown overlay={menu} placement="bottomRight" trigger={['click']}>
|
||||
<Button type="primary" icon={<UserOutlined />}>{this.props.user.getEmail()} <DownOutlined /></Button>
|
||||
</Dropdown>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default Header;
|
145
ui/src/components/Heatmap.tsx
Normal file
145
ui/src/components/Heatmap.tsx
Normal file
@ -0,0 +1,145 @@
|
||||
import React, { Component } from "react";
|
||||
|
||||
import { color } from "chart.js/helpers";
|
||||
import { Chart } from "react-chartjs-2";
|
||||
|
||||
|
||||
interface HeatmapData {
|
||||
x: string;
|
||||
y: Array<[string, number]>;
|
||||
}
|
||||
|
||||
|
||||
interface IProps {
|
||||
data: HeatmapData[];
|
||||
fromColor: string;
|
||||
toColor: string;
|
||||
}
|
||||
|
||||
|
||||
class Heatmap extends Component<IProps> {
|
||||
render() {
|
||||
if (this.props.data.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
let xSet: {[key: string]: any} = {};
|
||||
let ySet: {[key: string]: any} = {};
|
||||
|
||||
let dataData: {
|
||||
x: string;
|
||||
y: string;
|
||||
v: number;
|
||||
}[] = [];
|
||||
|
||||
let data = {
|
||||
labels: [],
|
||||
datasets: [
|
||||
{
|
||||
label: "Heatmap",
|
||||
data: dataData,
|
||||
minValue: -1,
|
||||
maxValue: -1,
|
||||
xSet: xSet,
|
||||
ySet: ySet,
|
||||
fromColor: this.props.fromColor.match(/\d+/g)!.map(Number),
|
||||
toColor: this.props.toColor.match(/\d+/g)!.map(Number),
|
||||
backgroundColor: (ctx: any): string => {
|
||||
if (ctx.dataset === undefined || ctx.dataset.data === undefined || ctx.dataset.data[ctx.dataIndex] === undefined) {
|
||||
return color('white').rgbString();
|
||||
}
|
||||
|
||||
const value = ctx.dataset.data[ctx.dataIndex].v;
|
||||
const steps = ctx.dataset.maxValue - ctx.dataset.minValue + 1;
|
||||
const step = value - ctx.dataset.minValue;
|
||||
const factor = 1 / steps * step;
|
||||
|
||||
let result: [number, number, number] = ctx.dataset.fromColor.slice();
|
||||
for (var i = 0; i < 3; i++) {
|
||||
result[i] = Math.round(result[i] + factor * (ctx.dataset.toColor[i] - ctx.dataset.fromColor[i]));
|
||||
}
|
||||
|
||||
return color(result).rgbString();
|
||||
},
|
||||
borderWidth: 0,
|
||||
width: (ctx: any) => {
|
||||
return (ctx.chart.chartArea || {}).width / Object.keys(ctx.dataset.xSet).length - 1;
|
||||
},
|
||||
height: (ctx: any) => {
|
||||
return (ctx.chart.chartArea || {}).height / Object.keys(ctx.dataset.ySet).length - 1;
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
let xLabels: string[] = [];
|
||||
|
||||
const animation: false = false;
|
||||
|
||||
let options = {
|
||||
animation: animation,
|
||||
maintainAspectRatio: false,
|
||||
scales: {
|
||||
y: {
|
||||
type: "category" as const,
|
||||
offset: true,
|
||||
grid: {
|
||||
display: false,
|
||||
},
|
||||
},
|
||||
x: {
|
||||
type: "time" as const,
|
||||
offset: true,
|
||||
labels: xLabels,
|
||||
grid: {
|
||||
display: false,
|
||||
},
|
||||
|
||||
},
|
||||
},
|
||||
plugins: {
|
||||
legend: {display: false},
|
||||
tooltip: {
|
||||
callbacks: {
|
||||
title: () => {
|
||||
return '';
|
||||
},
|
||||
label: (ctx: any) => {
|
||||
const v = ctx.dataset.data[ctx.dataIndex].v;
|
||||
return 'Count: ' + v;
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
for (const row of this.props.data) {
|
||||
options.scales.x.labels.push(row.x);
|
||||
data.datasets[0].xSet[row.x] = {};
|
||||
|
||||
for (const y of row.y) {
|
||||
data.datasets[0].ySet[y[0]] = {};
|
||||
|
||||
data.datasets[0].data.push({
|
||||
x: row.x,
|
||||
y: y[0],
|
||||
v: y[1],
|
||||
});
|
||||
|
||||
if (data.datasets[0].minValue === -1 || data.datasets[0].minValue > y[1]) {
|
||||
data.datasets[0].minValue = y[1];
|
||||
}
|
||||
|
||||
if (data.datasets[0].maxValue < y[1]) {
|
||||
data.datasets[0].maxValue = y[1];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return(
|
||||
<Chart type="matrix" data={data} options={options} />
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default Heatmap;
|
125
ui/src/components/LogTable.tsx
Normal file
125
ui/src/components/LogTable.tsx
Normal file
@ -0,0 +1,125 @@
|
||||
import React, { Component } from "react";
|
||||
import moment from "moment";
|
||||
import JSONTreeOriginal from "react-json-tree";
|
||||
|
||||
import { Tag, Drawer, Button, Table, Spin } from "antd";
|
||||
import { ZoomInOutlined } from '@ant-design/icons';
|
||||
|
||||
import { LogItem } from "@chirpstack/chirpstack-api-grpc-web/api/internal_pb";
|
||||
|
||||
|
||||
interface IProps {
|
||||
logs: LogItem[];
|
||||
}
|
||||
|
||||
interface IState {
|
||||
drawerOpen: boolean;
|
||||
body: any;
|
||||
};
|
||||
|
||||
|
||||
class LogTable extends Component<IProps, IState> {
|
||||
constructor(props: IProps) {
|
||||
super(props);
|
||||
|
||||
this.state = {
|
||||
drawerOpen: false,
|
||||
body: null,
|
||||
};
|
||||
}
|
||||
|
||||
onDrawerClose = () => {
|
||||
this.setState({
|
||||
drawerOpen: false,
|
||||
});
|
||||
}
|
||||
|
||||
onDrawerOpen = (body: any) => {
|
||||
return () => {
|
||||
this.setState({
|
||||
body: body,
|
||||
drawerOpen: true,
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
render() {
|
||||
let items = this.props.logs.map((l, i) => l.toObject());
|
||||
let body = JSON.parse(this.state.body);
|
||||
|
||||
const theme = {
|
||||
scheme: 'google',
|
||||
author: 'seth wright (http://sethawright.com)',
|
||||
base00: '#000000',
|
||||
base01: '#282a2e',
|
||||
base02: '#373b41',
|
||||
base03: '#969896',
|
||||
base04: '#b4b7b4',
|
||||
base05: '#c5c8c6',
|
||||
base06: '#e0e0e0',
|
||||
base07: '#ffffff',
|
||||
base08: '#CC342B',
|
||||
base09: '#F96A38',
|
||||
base0A: '#FBA922',
|
||||
base0B: '#198844',
|
||||
base0C: '#3971ED',
|
||||
base0D: '#3971ED',
|
||||
base0E: '#A36AC7',
|
||||
base0F: '#3971ED',
|
||||
};
|
||||
|
||||
return(
|
||||
<div>
|
||||
<Drawer title="Details" placement="right" width={650} onClose={this.onDrawerClose} visible={this.state.drawerOpen}>
|
||||
<JSONTreeOriginal
|
||||
data={body}
|
||||
theme={theme}
|
||||
hideRoot={true}
|
||||
shouldExpandNode={() => {return true}}
|
||||
/>
|
||||
</Drawer>
|
||||
{items.length !== 0 && <div className="spinner"><Spin /></div>}
|
||||
<Table
|
||||
showHeader={false}
|
||||
loading={items.length === 0}
|
||||
dataSource={items}
|
||||
pagination={false}
|
||||
columns={[
|
||||
{
|
||||
title: "Time",
|
||||
dataIndex: "time",
|
||||
key: "time",
|
||||
width: 200,
|
||||
render: (text, obj) => {
|
||||
let ts = new Date(0);
|
||||
ts.setUTCSeconds(obj.time!.seconds);
|
||||
return moment(ts).format("YYYY-MM-DD HH:mm:ss");
|
||||
},
|
||||
},
|
||||
{
|
||||
title: "Type",
|
||||
dataIndex: "description",
|
||||
key: "description",
|
||||
width: 200,
|
||||
render: (text, obj) => <Button icon={<ZoomInOutlined />} type="primary" shape="round" size="small" onClick={this.onDrawerOpen(obj.body)}>{text}</Button>,
|
||||
},
|
||||
{
|
||||
title: "Properties",
|
||||
dataIndex: "properties",
|
||||
key: "properties",
|
||||
render: (text, obj) => obj.propertiesMap.map((p, i) => {
|
||||
if (p[1] !== "") {
|
||||
return <Tag><pre>{p[0]}: {p[1]}</pre></Tag>
|
||||
}
|
||||
|
||||
return null;
|
||||
}),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default LogTable;
|
112
ui/src/components/Map.tsx
Normal file
112
ui/src/components/Map.tsx
Normal file
@ -0,0 +1,112 @@
|
||||
import React, { Component } from "react";
|
||||
|
||||
import L, { LatLngTuple, FitBoundsOptions } from "leaflet";
|
||||
import "leaflet.awesome-markers";
|
||||
import { MarkerProps as LMarkerProps } from "react-leaflet";
|
||||
import { MapContainer, Marker as LMarker, TileLayer } from 'react-leaflet';
|
||||
|
||||
|
||||
interface IProps {
|
||||
height: number;
|
||||
center?: [number, number];
|
||||
bounds?: LatLngTuple[];
|
||||
boundsOptions?: FitBoundsOptions;
|
||||
}
|
||||
|
||||
interface IState {
|
||||
map?: L.Map;
|
||||
}
|
||||
|
||||
|
||||
class Map extends Component<IProps, IState> {
|
||||
constructor(props: IProps) {
|
||||
super(props);
|
||||
this.state = {};
|
||||
}
|
||||
|
||||
setMap = (map: L.Map) => {
|
||||
this.setState({
|
||||
map: map,
|
||||
}, () => {
|
||||
// This is needed as setMap is called after the map has been created.
|
||||
// There is a small amount of time where componentDidUpdate can't update
|
||||
// the map with the new center because setMap hasn't been called yet.
|
||||
// In such case, the map would never update to the new center.
|
||||
if (this.props.center !== undefined) {
|
||||
map.panTo(this.props.center);
|
||||
}
|
||||
|
||||
if (this.props.bounds !== undefined) {
|
||||
map.fitBounds(this.props.bounds, this.props.boundsOptions);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
componentDidUpdate(oldProps: IProps) {
|
||||
if (this.props === oldProps) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.state.map) {
|
||||
if (this.props.center !== undefined) {
|
||||
this.state.map.flyTo(this.props.center);
|
||||
}
|
||||
|
||||
if (this.props.bounds !== undefined) {
|
||||
this.state.map.flyToBounds(this.props.bounds, this.props.boundsOptions);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
render() {
|
||||
const style = {
|
||||
height: this.props.height,
|
||||
};
|
||||
|
||||
return(
|
||||
<MapContainer
|
||||
bounds={this.props.bounds}
|
||||
boundsOptions={this.props.boundsOptions}
|
||||
center={this.props.center}
|
||||
zoom={13}
|
||||
scrollWheelZoom={false}
|
||||
animate={true}
|
||||
style={style}
|
||||
whenCreated={this.setMap}
|
||||
>
|
||||
<TileLayer
|
||||
attribution='© <a href="http://osm.org/copyright">OpenStreetMap</a> contributors'
|
||||
url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"
|
||||
/>
|
||||
{this.props.children}
|
||||
</MapContainer>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export type MarkerColor = "red" | "darkred" | "orange" | "green" | "darkgreen" | "blue" | "purple" | "darkpurple" | "cadetblue" | undefined;
|
||||
|
||||
interface MarkerProps extends LMarkerProps {
|
||||
position: [number, number];
|
||||
faIcon: string;
|
||||
color: MarkerColor;
|
||||
}
|
||||
|
||||
export class Marker extends Component<MarkerProps> {
|
||||
render() {
|
||||
const { faIcon, color, position, ...otherProps } = this.props;
|
||||
|
||||
const iconMarker = L.AwesomeMarkers.icon({
|
||||
icon: faIcon,
|
||||
prefix: "fa",
|
||||
markerColor: color,
|
||||
});
|
||||
|
||||
|
||||
return(
|
||||
<LMarker icon={iconMarker} position={position} {...otherProps}>{this.props.children}</LMarker>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default Map;
|
179
ui/src/components/Menu.tsx
Normal file
179
ui/src/components/Menu.tsx
Normal file
@ -0,0 +1,179 @@
|
||||
import React, { Component } from "react";
|
||||
import { withRouter, RouteComponentProps, Link } from "react-router-dom";
|
||||
|
||||
import { Menu } from "antd";
|
||||
import { CloudOutlined, HomeOutlined, UserOutlined, DashboardOutlined, KeyOutlined, WifiOutlined, ControlOutlined, AppstoreOutlined } from "@ant-design/icons";
|
||||
|
||||
import { GetTenantResponse, ListTenantsRequest, ListTenantsResponse } from "@chirpstack/chirpstack-api-grpc-web/api/tenant_pb";
|
||||
|
||||
import Autocomplete, { OptionCallbackFunc, OptionsCallbackFunc } from "../components/Autocomplete";
|
||||
import TenantStore from "../stores/TenantStore";
|
||||
import SessionStore from "../stores/SessionStore";
|
||||
|
||||
const { SubMenu } = Menu;
|
||||
|
||||
interface IState {
|
||||
tenantId: string;
|
||||
selectedKey: string;
|
||||
}
|
||||
|
||||
class SideMenu extends Component<RouteComponentProps, IState> {
|
||||
constructor(props: RouteComponentProps) {
|
||||
super(props);
|
||||
|
||||
this.state = {
|
||||
tenantId: "",
|
||||
selectedKey: "ns-dashboard",
|
||||
};
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
SessionStore.on("tenant.change", this.setTenant);
|
||||
this.setTenant();
|
||||
this.parseLocation();
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
SessionStore.removeListener("tenant.change", this.setTenant);
|
||||
}
|
||||
|
||||
componentDidUpdate(prevProps: RouteComponentProps) {
|
||||
if (this.props === prevProps) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.parseLocation();
|
||||
}
|
||||
|
||||
setTenant = () => {
|
||||
this.setState({
|
||||
tenantId: SessionStore.getTenantId(),
|
||||
});
|
||||
}
|
||||
|
||||
getTenantOptions = (search: string, fn: OptionsCallbackFunc) => {
|
||||
let req = new ListTenantsRequest();
|
||||
req.setSearch(search);
|
||||
req.setLimit(10);
|
||||
|
||||
TenantStore.list(req, (resp: ListTenantsResponse) => {
|
||||
const options = resp.getResultList().map((o, i) => {return {label: o.getName(), value: o.getId()}});
|
||||
fn(options);
|
||||
});
|
||||
}
|
||||
|
||||
getTenantOption = (id: string, fn: OptionCallbackFunc) => {
|
||||
TenantStore.get(id, (resp: GetTenantResponse) => {
|
||||
const tenant = resp.getTenant();
|
||||
if (tenant) {
|
||||
fn({label: tenant.getName(), value: tenant.getId()});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
onTenantSelect = (value: string) => {
|
||||
SessionStore.setTenantId(value);
|
||||
this.props.history.push(`/tenants/${value}`);
|
||||
}
|
||||
|
||||
parseLocation = () => {
|
||||
const path = this.props.history.location.pathname;
|
||||
const tenantRe = /\/tenants\/([\w-]{36})/g;
|
||||
const match = tenantRe.exec(path);
|
||||
|
||||
if (match !== null && (this.state.tenantId !== match[1])) {
|
||||
SessionStore.setTenantId(match[1]);
|
||||
}
|
||||
|
||||
// ns dashboard
|
||||
if (path === "/dashboard") {
|
||||
this.setState({selectedKey: "ns-dashboard"});
|
||||
}
|
||||
|
||||
// ns tenants
|
||||
if (/\/tenants(\/([\w-]{36}\/edit|create))?/g.exec(path)) {
|
||||
this.setState({selectedKey: "ns-tenants"});
|
||||
}
|
||||
|
||||
// ns tenants
|
||||
if (/\/users(\/([\w-]{36}\/edit|create))?/g.exec(path)) {
|
||||
this.setState({selectedKey: "ns-users"});
|
||||
}
|
||||
|
||||
// ns api keys
|
||||
if (/\/api-keys(\/([\w-]{36}\/edit|create))?/g.exec(path)) {
|
||||
this.setState({selectedKey: "ns-api-keys"});
|
||||
}
|
||||
|
||||
|
||||
// tenant dashboard
|
||||
if (/\/tenants\/[\w-]{36}/g.exec(path)) {
|
||||
this.setState({selectedKey: "tenant-dashboard"});
|
||||
}
|
||||
|
||||
// tenant users
|
||||
if (/\/tenants\/[\w-]{36}\/users.*/g.exec(path)) {
|
||||
this.setState({selectedKey: "tenant-users"});
|
||||
}
|
||||
|
||||
// tenant api-keys
|
||||
if (/\/tenants\/[\w-]{36}\/api-keys.*/g.exec(path)) {
|
||||
this.setState({selectedKey: "tenant-api-keys"});
|
||||
}
|
||||
|
||||
// tenant device-profiles
|
||||
if (/\/tenants\/[\w-]{36}\/device-profiles.*/g.exec(path)) {
|
||||
this.setState({selectedKey: "tenant-device-profiles"});
|
||||
}
|
||||
|
||||
// tenant gateways
|
||||
if (/\/tenants\/[\w-]{36}\/gateways.*/g.exec(path)) {
|
||||
this.setState({selectedKey: "tenant-gateways"});
|
||||
}
|
||||
|
||||
// tenant applications
|
||||
if (/\/tenants\/[\w-]{36}\/applications.*/g.exec(path)) {
|
||||
this.setState({selectedKey: "tenant-applications"});
|
||||
}
|
||||
}
|
||||
|
||||
render() {
|
||||
const tenantId = this.state.tenantId;
|
||||
|
||||
return(
|
||||
<div>
|
||||
<Autocomplete
|
||||
placeholder="Select tenant"
|
||||
className="organiation-select"
|
||||
getOption={this.getTenantOption}
|
||||
getOptions={this.getTenantOptions}
|
||||
onSelect={this.onTenantSelect}
|
||||
value={this.state.tenantId}
|
||||
/>
|
||||
<Menu
|
||||
mode="inline"
|
||||
openKeys={["ns", "tenant"]}
|
||||
selectedKeys={[this.state.selectedKey]}
|
||||
expandIcon={<div></div>}
|
||||
>
|
||||
{SessionStore.isAdmin() && <SubMenu key="ns" title="Network Server" icon={<CloudOutlined />}>
|
||||
<Menu.Item key="ns-dashboard" icon={<DashboardOutlined />}><Link to="/dashboard">Dashboard</Link></Menu.Item>
|
||||
<Menu.Item key="ns-tenants" icon={<HomeOutlined />}><Link to="/tenants">Tenants</Link></Menu.Item>
|
||||
<Menu.Item key="ns-users" icon={<UserOutlined />}><Link to="/users">Users</Link></Menu.Item>
|
||||
<Menu.Item key="ns-api-keys" icon={<KeyOutlined />}><Link to="/api-keys">API keys</Link></Menu.Item>
|
||||
</SubMenu>}
|
||||
{tenantId !== "" && <SubMenu key="tenant" title="Tenant" icon={<HomeOutlined />}>
|
||||
<Menu.Item key="tenant-dashboard" icon={<DashboardOutlined />}><Link to={`/tenants/${tenantId}`}>Dashboard</Link></Menu.Item>
|
||||
<Menu.Item key="tenant-users" icon={<UserOutlined />}><Link to={`/tenants/${tenantId}/users`}>Users</Link></Menu.Item>
|
||||
<Menu.Item key="tenant-api-keys" icon={<KeyOutlined />}><Link to={`/tenants/${tenantId}/api-keys`}>API keys</Link></Menu.Item>
|
||||
<Menu.Item key="tenant-device-profiles" icon={<ControlOutlined />}><Link to={`/tenants/${tenantId}/device-profiles`}>Device profiles</Link></Menu.Item>
|
||||
<Menu.Item key="tenant-gateways" icon={<WifiOutlined />}><Link to={`/tenants/${tenantId}/gateways`}>Gateways</Link></Menu.Item>
|
||||
<Menu.Item key="tenant-applications" icon={<AppstoreOutlined />}><Link to={`/tenants/${tenantId}/applications`}>Applications</Link></Menu.Item>
|
||||
</SubMenu>}
|
||||
</Menu>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default withRouter(SideMenu);
|
Reference in New Issue
Block a user