Initial commit.

This commit is contained in:
Orne Brocaar
2022-04-06 21:18:32 +01:00
commit 96fe672fc7
709 changed files with 335482 additions and 0 deletions

View 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;

View 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;

View 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;

View 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;

View 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

View 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;

View 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;

View 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;

View 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;

View 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;

View 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;

View 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
View 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='&copy; <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
View 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);