Reworked client to preact
This commit is contained in:
parent
a26e81f22d
commit
fe204aa77d
|
|
@ -0,0 +1,61 @@
|
|||
module.exports = {
|
||||
root: true,
|
||||
parser: '@typescript-eslint/parser',
|
||||
extends: [
|
||||
'plugin:react/recommended',
|
||||
'plugin:@typescript-eslint/recommended',
|
||||
'plugin:prettier/recommended',
|
||||
'plugin:react/jsx-runtime',
|
||||
],
|
||||
plugins: [],
|
||||
parserOptions: {
|
||||
ecmaVersion: 2018,
|
||||
sourceType: 'module',
|
||||
ecmaFeatures: {
|
||||
jsx: true,
|
||||
},
|
||||
},
|
||||
rules: {
|
||||
indent: 'off',
|
||||
curly: ['warn', 'all'],
|
||||
'padding-line-between-statements': [
|
||||
'warn',
|
||||
{
|
||||
blankLine: 'always',
|
||||
prev: '*',
|
||||
next: [
|
||||
'block-like',
|
||||
'multiline-expression',
|
||||
'multiline-const',
|
||||
'return',
|
||||
],
|
||||
},
|
||||
{
|
||||
blankLine: 'always',
|
||||
prev: ['block-like', 'multiline-expression', 'multiline-const'],
|
||||
next: '*',
|
||||
},
|
||||
],
|
||||
'react/prop-types': 'off',
|
||||
'@typescript-eslint/indent': 'off',
|
||||
'@typescript-eslint/no-use-before-define': 'off',
|
||||
'@typescript-eslint/explicit-member-accessibility': [
|
||||
'warn',
|
||||
{ accessibility: 'no-public' },
|
||||
],
|
||||
'@typescript-eslint/explicit-function-return-type': 'off',
|
||||
'@typescript-eslint/member-delimiter-style': [
|
||||
'warn',
|
||||
{ multiline: { delimiter: 'none' } },
|
||||
],
|
||||
'@typescript-eslint/no-object-literal-type-assertion': 'off',
|
||||
'@typescript-eslint/prefer-interface': 'off',
|
||||
'prettier/prettier': 'warn',
|
||||
'react/display-name': 'off',
|
||||
},
|
||||
settings: {
|
||||
react: {
|
||||
version: 'detect',
|
||||
},
|
||||
},
|
||||
}
|
||||
|
|
@ -0,0 +1 @@
|
|||
node_modules
|
||||
|
|
@ -0,0 +1,5 @@
|
|||
module.exports = {
|
||||
useTabs: true,
|
||||
semi: false,
|
||||
singleQuote: true,
|
||||
}
|
||||
|
|
@ -4,12 +4,11 @@
|
|||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Sensors</title>
|
||||
<link rel="stylesheet" href="css/style.css" />
|
||||
<!-- TODO: This should be loaded locally -->
|
||||
<script src="https://cdn.plot.ly/plotly-2.14.0.min.js"></script>
|
||||
<script src="js/lighterhtml@4.2.0.min.js"></script>
|
||||
</head>
|
||||
<body>
|
||||
<div id="sensors-container"></div>
|
||||
<script src="js/index.js"></script>
|
||||
<div id="application"></div>
|
||||
<script type="module" src="/src/index.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
|
|
|||
|
|
@ -1,397 +0,0 @@
|
|||
const { html, render } = lighterhtml;
|
||||
|
||||
const $container = document.getElementById("sensors-container");
|
||||
|
||||
const $filters = document.createElement("div");
|
||||
$filters.className = "filters";
|
||||
$container.appendChild($filters);
|
||||
|
||||
const $sensorsContainer = document.createElement("div");
|
||||
$sensorsContainer.className = "sensors";
|
||||
$container.appendChild($sensorsContainer);
|
||||
|
||||
const $config = document.createElement("div");
|
||||
$config.className = "config";
|
||||
$container.appendChild($config);
|
||||
|
||||
let sensorComponents = [];
|
||||
|
||||
function load() {
|
||||
fetch("/api/sensors")
|
||||
.then((r) => r.json())
|
||||
.then((sensors) => {
|
||||
$sensorsContainer.innerHTML = "";
|
||||
|
||||
sensorComponents = sensors.map((sensor) => createSensor(sensor));
|
||||
sensorComponents.forEach((component) =>
|
||||
$sensorsContainer.appendChild(component.container)
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
function preventPropagation(e) {
|
||||
e.stopPropagation();
|
||||
}
|
||||
|
||||
function hideConfig() {
|
||||
renderConfig({ sensor: { config: {} }, shown: false });
|
||||
}
|
||||
|
||||
function showConfigOf(sensor, onSave) {
|
||||
renderConfig({ sensor, onSave, shown: true });
|
||||
}
|
||||
|
||||
function renderConfig({ sensor, onSave, shown }) {
|
||||
const handleSave = async (e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
const data = Object.fromEntries(new FormData(e.target));
|
||||
|
||||
Promise.all(
|
||||
Object.entries(data).map(([name, value]) =>
|
||||
fetch(`/api/sensors/${sensor.sensor}/config/${name}`, {
|
||||
method: "PUT",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ value }),
|
||||
})
|
||||
)
|
||||
).then(() => onSave?.());
|
||||
|
||||
hideConfig();
|
||||
};
|
||||
|
||||
const config = sensor.config;
|
||||
|
||||
render(
|
||||
$config,
|
||||
html`
|
||||
<div class="settings-modal${shown ? " show" : ""}" onclick=${hideConfig}>
|
||||
<div class="inner" onclick=${preventPropagation}>
|
||||
<div class="body">
|
||||
<form onsubmit="${handleSave}">
|
||||
<div class="input">
|
||||
<label>Sensor</label>
|
||||
<input value="${sensor.sensor}" disabled />
|
||||
</div>
|
||||
<div class="input">
|
||||
<label>Name</label>
|
||||
<input name="name" value="${config.name}" />
|
||||
</div>
|
||||
<div class="input">
|
||||
<label>Type</label>
|
||||
<select name="graphType" value="${config.graphType || 'line'}">
|
||||
<option value="line">Line</option>
|
||||
<option value="points">Points</option>
|
||||
<option value="lineAndPoints">Line + Points</option>
|
||||
<option value="bar">Bar</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="input">
|
||||
<label>Unit</label>
|
||||
<input name="unit" value="${config.unit}" />
|
||||
</div>
|
||||
<div class="input">
|
||||
<label>Min value</label>
|
||||
<input name="min" value="${config.min}" />
|
||||
</div>
|
||||
<div class="input">
|
||||
<label>Max value</label>
|
||||
<input name="max" value="${config.max}" />
|
||||
</div>
|
||||
|
||||
<div class="actions">
|
||||
<button class="cancel" onclick=${hideConfig} type="button">Cancel</button>
|
||||
<button>Save</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`
|
||||
);
|
||||
}
|
||||
|
||||
const CURRENT_FILTERS = loadFilters();
|
||||
|
||||
function loadFilters() {
|
||||
const params = new URLSearchParams(location.search.replace(/^\?/, ""));
|
||||
|
||||
return {
|
||||
interval: params.get("interval") || "week",
|
||||
refresh: "1s",
|
||||
customFrom: params.get("from") || new Date().toISOString(),
|
||||
customTo: params.get("to") || new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
function saveFilters() {
|
||||
const values = {
|
||||
interval: CURRENT_FILTERS.interval,
|
||||
};
|
||||
|
||||
if (CURRENT_FILTERS.interval === "custom") {
|
||||
values.from = CURRENT_FILTERS.customFrom.toISOString();
|
||||
values.to = CURRENT_FILTERS.customTo.toISOString();
|
||||
}
|
||||
|
||||
const url = new URL(location.href);
|
||||
const params = new URLSearchParams(values);
|
||||
|
||||
url.search = params.toString();
|
||||
|
||||
history.replaceState(null, null, url.toString());
|
||||
}
|
||||
|
||||
function refreshAll() {
|
||||
sensorComponents.forEach((c) => c.refreshValues());
|
||||
}
|
||||
|
||||
let lastIntervalSelected = CURRENT_FILTERS.interval;
|
||||
|
||||
function splitDateTime(v) {
|
||||
const d = new Date(v);
|
||||
|
||||
const date =
|
||||
d.getFullYear() +
|
||||
"-" +
|
||||
(d.getMonth() + 1).toString().padStart(2, "0") +
|
||||
"-" +
|
||||
d.getDate().toString().padStart(2, "0");
|
||||
const time =
|
||||
d.getHours().toString().padStart(2, "0") +
|
||||
":" +
|
||||
d.getMinutes().toString().padStart(2, "0");
|
||||
|
||||
return [date, time];
|
||||
}
|
||||
|
||||
function renderFilters() {
|
||||
const handleApply = async (e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
const data = Object.fromEntries(new FormData(e.target));
|
||||
|
||||
CURRENT_FILTERS.interval = data.interval;
|
||||
|
||||
if (data.interval !== "custom") {
|
||||
const [from, to] = getDataInterval(CURRENT_FILTERS.interval);
|
||||
CURRENT_FILTERS.customFrom = from.toISOString();
|
||||
CURRENT_FILTERS.customTo = to.toISOString();
|
||||
} else {
|
||||
CURRENT_FILTERS.customFrom = new Date(
|
||||
`${data.fromDate} ${data.fromTime}`
|
||||
);
|
||||
CURRENT_FILTERS.customTo = new Date(`${data.toDate} ${data.toTime}`);
|
||||
}
|
||||
|
||||
refreshAll();
|
||||
renderFilters();
|
||||
saveFilters();
|
||||
};
|
||||
|
||||
const handleIntervalChange = (e) => {
|
||||
lastIntervalSelected = e.target.value;
|
||||
|
||||
renderFilters();
|
||||
};
|
||||
|
||||
const customFrom = splitDateTime(CURRENT_FILTERS.customFrom);
|
||||
const customTo = splitDateTime(CURRENT_FILTERS.customTo);
|
||||
const isCustomSelected = lastIntervalSelected === "custom";
|
||||
|
||||
render(
|
||||
$filters,
|
||||
html`
|
||||
<div class="inner">
|
||||
<div class="filter-form">
|
||||
<form class="horizontal" onsubmit=${handleApply}>
|
||||
<div class="input">
|
||||
<label>Interval</label>
|
||||
<select
|
||||
name="interval"
|
||||
value="${lastIntervalSelected}"
|
||||
onchange=${handleIntervalChange}
|
||||
>
|
||||
<option value="hour">Hour</option>
|
||||
<option value="day">Day</option>
|
||||
<option value="week">Week</option>
|
||||
<option value="month">Month</option>
|
||||
<option value="custom">Custom</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
${isCustomSelected
|
||||
? html`
|
||||
<div class="input date-time">
|
||||
<label>From</label>
|
||||
<div>
|
||||
<input
|
||||
type="date"
|
||||
value="${customFrom[0]}"
|
||||
name="fromDate"
|
||||
/>
|
||||
<input
|
||||
type="time"
|
||||
value="${customFrom[1]}"
|
||||
name="fromTime"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="input date-time">
|
||||
<label>To</label>
|
||||
<div>
|
||||
<input type="date" value="${customTo[0]}" name="toDate" />
|
||||
<input type="time" value="${customTo[1]}" name="toTime" />
|
||||
</div>
|
||||
</div>
|
||||
`
|
||||
: undefined}
|
||||
|
||||
<button>Apply</button>
|
||||
</form>
|
||||
</div>
|
||||
<div class="actions">
|
||||
<!--TODO:-->
|
||||
<!--<label class="checkbox-label"><input type="checkbox"><span>auto-refresh</span></label>-->
|
||||
<button onclick=${refreshAll}>Refresh</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="shadow"></div>
|
||||
`
|
||||
);
|
||||
}
|
||||
|
||||
function getDataInterval() {
|
||||
switch (CURRENT_FILTERS.interval) {
|
||||
case "hour": {
|
||||
return [new Date(Date.now() - 3600 * 1000), new Date()];
|
||||
}
|
||||
case "day": {
|
||||
return [new Date(Date.now() - 24 * 3600 * 1000), new Date()];
|
||||
}
|
||||
case "week": {
|
||||
return [new Date(Date.now() - 5 * 24 * 3600 * 1000), new Date()];
|
||||
}
|
||||
case "month": {
|
||||
return [new Date(Date.now() - 30 * 24 * 3600 * 1000), new Date()];
|
||||
}
|
||||
case "year": {
|
||||
return [new Date(Date.now() - 356 * 24 * 3600 * 1000), new Date()];
|
||||
}
|
||||
case "custom": {
|
||||
return [
|
||||
new Date(CURRENT_FILTERS.customFrom),
|
||||
new Date(CURRENT_FILTERS.customTo),
|
||||
];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function createSensor(sensor) {
|
||||
const container = document.createElement("div");
|
||||
container.className = "sensor";
|
||||
|
||||
const header = document.createElement("div");
|
||||
header.className = "header";
|
||||
|
||||
const body = document.createElement("div");
|
||||
body.className = "body";
|
||||
|
||||
const showSettings = () => {
|
||||
showConfigOf(sensor, () => refreshSensorConfig());
|
||||
};
|
||||
|
||||
const renderHeader = () => {
|
||||
render(
|
||||
header,
|
||||
html`
|
||||
<div class="name">${sensor.config?.name ?? sensor.sensor}</div>
|
||||
<div class="actions">
|
||||
<button class="config" onClick=${showSettings}>Config</button>
|
||||
<button class="refresh" onClick=${refreshValues}>Refresh</button>
|
||||
</div>
|
||||
`
|
||||
);
|
||||
};
|
||||
|
||||
const renderBody = (range, values) => {
|
||||
const { from, to } = range;
|
||||
const minValue = parseFloat(sensor.config.min);
|
||||
const maxValue = parseFloat(sensor.config.max);
|
||||
const customRange = !isNaN(minValue) && !isNaN(maxValue);
|
||||
|
||||
Plotly.newPlot(
|
||||
body,
|
||||
[
|
||||
{
|
||||
...(sensor.config.graphType === 'line' && { type: 'scatter', mode: 'lines' }),
|
||||
...(sensor.config.graphType === 'points' && { type: 'scatter', mode: 'markers' }),
|
||||
...(sensor.config.graphType === 'lineAndPoints' && { type: 'scatter', mode: 'lines+markers' }),
|
||||
...(sensor.config.graphType === 'bar' && { type: 'bar' }),
|
||||
x: values.map((v) => new Date(v.timestamp * 1000)),
|
||||
y: values.map((v) => v.value),
|
||||
line: {
|
||||
width: 1
|
||||
}
|
||||
},
|
||||
],
|
||||
{
|
||||
xaxis: { range: [from, to], type: "date" },
|
||||
yaxis: {
|
||||
...(customRange && { range: [minValue, maxValue] }),
|
||||
...(sensor.config.unit && { ticksuffix: ` ${sensor.config.unit}` }),
|
||||
},
|
||||
margin: {
|
||||
l: 70,
|
||||
r: 20,
|
||||
b: 60,
|
||||
t: 20,
|
||||
pad: 5,
|
||||
},
|
||||
height: 300
|
||||
},
|
||||
{
|
||||
responsive: true,
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
const refreshValues = () => {
|
||||
const [from, to] = getDataInterval(CURRENT_FILTERS.interval);
|
||||
|
||||
fetch(
|
||||
`/api/sensors/${sensor.sensor}/values?from=${Math.round(
|
||||
from.getTime() / 1000
|
||||
)}&to=${Math.round(to.getTime() / 1000)}`
|
||||
)
|
||||
.then((r) => r.json())
|
||||
.then((values) => renderBody({ from, to }, values ?? []));
|
||||
};
|
||||
|
||||
const refreshSensorConfig = () => {
|
||||
fetch(`/api/sensors/${sensor.sensor}/config`)
|
||||
.then((r) => r.json())
|
||||
.then((c) => (sensor.config = c))
|
||||
.then(() => {
|
||||
renderHeader();
|
||||
refreshValues();
|
||||
});
|
||||
};
|
||||
|
||||
renderHeader();
|
||||
refreshValues();
|
||||
|
||||
container.appendChild(header);
|
||||
container.appendChild(body);
|
||||
|
||||
return {
|
||||
container,
|
||||
refreshValues,
|
||||
};
|
||||
}
|
||||
|
||||
load();
|
||||
renderFilters();
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because it is too large
Load Diff
|
|
@ -0,0 +1,31 @@
|
|||
{
|
||||
"name": "client",
|
||||
"version": "1.0.0",
|
||||
"description": "",
|
||||
"main": "index.js",
|
||||
"scripts": {
|
||||
"start": "vite",
|
||||
"build": "vite build"
|
||||
},
|
||||
"author": "",
|
||||
"license": "ISC",
|
||||
"devDependencies": {
|
||||
"@preact/preset-vite": "^2.3.0",
|
||||
"@types/plotly.js": "^2.12.2",
|
||||
"@typescript-eslint/eslint-plugin": "^5.7.0",
|
||||
"@typescript-eslint/parser": "^5.7.0",
|
||||
"eslint": "^8.5.0",
|
||||
"eslint-config-prettier": "^8.5.0",
|
||||
"eslint-plugin-prettier": "^4.0.0",
|
||||
"eslint-plugin-react": "^7.30.0",
|
||||
"prettier": "^2.5.1",
|
||||
"ts-node": "^8.10.1",
|
||||
"typescript": "^4.3.2",
|
||||
"vite": "^3.0.9"
|
||||
},
|
||||
"dependencies": {
|
||||
"plotly.js": "^2.14.0",
|
||||
"preact": "^10.10.6",
|
||||
"react-query": "^3.39.2"
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,15 @@
|
|||
import { QueryClient, QueryClientProvider } from 'react-query'
|
||||
import { AppContextProvider } from './contexts/AppContext'
|
||||
import { Router } from './pages/Router'
|
||||
|
||||
const queryClient = new QueryClient()
|
||||
|
||||
export const Root = () => {
|
||||
return (
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<AppContextProvider>
|
||||
<Router />
|
||||
</AppContextProvider>
|
||||
</QueryClientProvider>
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1,20 @@
|
|||
import { request } from './request'
|
||||
|
||||
export const login = ({
|
||||
username,
|
||||
password,
|
||||
}: {
|
||||
username: string
|
||||
password: string
|
||||
}) =>
|
||||
request(
|
||||
'/api/login',
|
||||
{
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'content-type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ username, password }),
|
||||
},
|
||||
'void'
|
||||
)
|
||||
|
|
@ -0,0 +1,45 @@
|
|||
export class RequestError extends Error {
|
||||
constructor(public response: Response, message: string) {
|
||||
super(message)
|
||||
}
|
||||
}
|
||||
|
||||
export class ResponseEmptyError extends RequestError {
|
||||
constructor(response: Response, message: string) {
|
||||
super(response, message)
|
||||
}
|
||||
}
|
||||
|
||||
export const request = async <T = void>(
|
||||
url: string,
|
||||
options?: RequestInit,
|
||||
type: 'json' | 'void' = 'json'
|
||||
) => {
|
||||
const response = await fetch(url, options)
|
||||
|
||||
if (!response.ok) {
|
||||
throw new RequestError(
|
||||
response,
|
||||
`${response.status} ${response.statusText}`
|
||||
)
|
||||
}
|
||||
|
||||
if (type === 'void') {
|
||||
return
|
||||
}
|
||||
|
||||
const text = await response.text()
|
||||
|
||||
if (!text) {
|
||||
if (type === 'json') {
|
||||
throw new ResponseEmptyError(
|
||||
response,
|
||||
'Expected json, got empty response'
|
||||
)
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
return JSON.parse(text) as T
|
||||
}
|
||||
|
|
@ -0,0 +1,20 @@
|
|||
import { request } from './request'
|
||||
|
||||
export const setSensorConfig = ({
|
||||
sensor,
|
||||
name,
|
||||
value,
|
||||
}: {
|
||||
sensor: string
|
||||
name: string
|
||||
value: string
|
||||
}) =>
|
||||
request(
|
||||
`/api/sensors/${encodeURI(sensor)}/config/${name}`,
|
||||
{
|
||||
method: 'PUT',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({ value }),
|
||||
},
|
||||
'void'
|
||||
)
|
||||
|
|
@ -0,0 +1,21 @@
|
|||
import { request } from './request'
|
||||
|
||||
export type SensorValue = {
|
||||
timestamp: number
|
||||
value: number
|
||||
}
|
||||
|
||||
export const getSensorValues = ({
|
||||
from,
|
||||
to,
|
||||
sensor,
|
||||
}: {
|
||||
from: Date
|
||||
to: Date
|
||||
sensor: string
|
||||
}) =>
|
||||
request<SensorValue[]>(
|
||||
`/api/sensors/${encodeURI(sensor)}/values?from=${Math.round(
|
||||
from.getTime() / 1000
|
||||
)}&to=${Math.round(to.getTime() / 1000)}`
|
||||
)
|
||||
|
|
@ -0,0 +1,8 @@
|
|||
import { request } from './request'
|
||||
|
||||
export type SensorInfo = {
|
||||
sensor: string
|
||||
config: Record<string, string>
|
||||
}
|
||||
|
||||
export const getSensors = () => request<SensorInfo[]>('/api/sensors')
|
||||
|
|
@ -32,6 +32,15 @@ input, select {
|
|||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.login {
|
||||
width: 300px;
|
||||
margin: 2rem auto;
|
||||
}
|
||||
|
||||
.login .box {
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
#sensors-container {
|
||||
height: 100%;
|
||||
overflow: auto;
|
||||
|
|
@ -54,6 +63,12 @@ input, select {
|
|||
}
|
||||
}
|
||||
|
||||
.box {
|
||||
box-shadow: 0px 10px 15px -3px rgba(0,0,0,0.1);
|
||||
background-color: #fff;
|
||||
border-radius: 0.5rem;
|
||||
}
|
||||
|
||||
.sensor {
|
||||
background: #fff;
|
||||
border-radius: 0.5rem;
|
||||
|
|
@ -0,0 +1,31 @@
|
|||
import { splitDateTime } from '@/utils/splitDateTime'
|
||||
|
||||
type Props = {
|
||||
value: Date
|
||||
onChange: (value: Date) => void
|
||||
}
|
||||
|
||||
export const DateTimeInput = ({ value, onChange }: Props) => {
|
||||
const splitValue = splitDateTime(value)
|
||||
|
||||
const handleDateChange = (e: Event) => {
|
||||
const target = e.target as HTMLInputElement
|
||||
const value = target.value
|
||||
|
||||
onChange(new Date(`${value} ${splitValue[1]}`))
|
||||
}
|
||||
|
||||
const handleTimeChange = (e: Event) => {
|
||||
const target = e.target as HTMLInputElement
|
||||
const value = target.value
|
||||
|
||||
onChange(new Date(`${splitValue[0]} ${value}`))
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<input type="date" value={splitValue[0]} onChange={handleDateChange} />
|
||||
<input type="time" value={splitValue[1]} onChange={handleTimeChange} />
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1,34 @@
|
|||
import { ComponentChildren, createContext } from 'preact'
|
||||
import { useContext, useMemo, useState } from 'preact/hooks'
|
||||
|
||||
export type AppContextType = {
|
||||
loggedIn: boolean
|
||||
setLoggedIn: (loggedIn: boolean) => void
|
||||
}
|
||||
|
||||
export const AppContext = createContext<AppContextType | null>(null)
|
||||
|
||||
type Props = {
|
||||
children: ComponentChildren
|
||||
}
|
||||
|
||||
export const AppContextProvider = ({ children }: Props) => {
|
||||
const [loggedIn, setLoggedIn] = useState(false)
|
||||
|
||||
const value = useMemo(
|
||||
() => ({ loggedIn, setLoggedIn }),
|
||||
[loggedIn, setLoggedIn]
|
||||
)
|
||||
|
||||
return <AppContext.Provider value={value}>{children}</AppContext.Provider>
|
||||
}
|
||||
|
||||
export const useAppContext = () => {
|
||||
const ctx = useContext(AppContext)
|
||||
|
||||
if (!ctx) {
|
||||
throw new Error('useAppContext used outside AppContext')
|
||||
}
|
||||
|
||||
return ctx
|
||||
}
|
||||
|
|
@ -0,0 +1,7 @@
|
|||
import { Root } from './Root'
|
||||
import { render } from 'preact'
|
||||
|
||||
import './assets/style.css'
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
render(<Root />, document.getElementById('application')!)
|
||||
|
|
@ -0,0 +1,14 @@
|
|||
import { useAppContext } from '@/contexts/AppContext'
|
||||
import { DashboardPage } from './dashboard/DashboardPage'
|
||||
import { LoginPage } from './login/LoginPage'
|
||||
|
||||
export const Router = () => {
|
||||
const { loggedIn } = useAppContext()
|
||||
|
||||
return (
|
||||
<>
|
||||
{loggedIn && <DashboardPage />}
|
||||
{!loggedIn && <LoginPage />}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1,27 @@
|
|||
import { getSensors } from '@/api/sensors'
|
||||
import { intervalToRange } from '@/utils/intervalToRange'
|
||||
import { useState } from 'preact/hooks'
|
||||
import { useQuery } from 'react-query'
|
||||
import { Filters, FilterValue } from './components/Filters'
|
||||
import { Sensor } from './components/Sensor'
|
||||
|
||||
export const DashboardPage = () => {
|
||||
const [filter, setFilter] = useState<FilterValue>(() => {
|
||||
const range = intervalToRange('week', new Date(), new Date())
|
||||
|
||||
return { interval: 'week', customFrom: range[0], customTo: range[1] }
|
||||
})
|
||||
|
||||
const sensors = useQuery(['/sensors'], getSensors)
|
||||
|
||||
return (
|
||||
<>
|
||||
<Filters preset={filter} onApply={setFilter} />
|
||||
<div className="sensors">
|
||||
{sensors.data?.map((s) => (
|
||||
<Sensor sensor={s} filter={filter} key={s.sensor} />
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1,104 @@
|
|||
import { DateTimeInput } from '@/components/DateTimeInput'
|
||||
import { intervalToRange } from '@/utils/intervalToRange'
|
||||
import { useState } from 'preact/hooks'
|
||||
|
||||
export type FilterInterval =
|
||||
| 'hour'
|
||||
| 'day'
|
||||
| 'week'
|
||||
| 'month'
|
||||
| 'year'
|
||||
| 'custom'
|
||||
|
||||
export type FilterValue = {
|
||||
interval: FilterInterval
|
||||
customFrom: Date
|
||||
customTo: Date
|
||||
}
|
||||
|
||||
type Props = {
|
||||
preset: FilterValue
|
||||
onApply: (v: FilterValue) => void
|
||||
}
|
||||
|
||||
export const Filters = ({ preset, onApply }: Props) => {
|
||||
const [value, setValue] = useState(preset)
|
||||
|
||||
const isCustomSelected = value.interval === 'custom'
|
||||
|
||||
const handleSubmit = (e: Event) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
|
||||
onApply(value)
|
||||
}
|
||||
|
||||
const handleIntervalChange = (e: Event) => {
|
||||
const target = e.target as HTMLSelectElement
|
||||
const interval = target.value as FilterInterval
|
||||
const range = intervalToRange(interval, value.customFrom, value.customTo)
|
||||
|
||||
setValue({
|
||||
...value,
|
||||
interval,
|
||||
customFrom: range[0],
|
||||
customTo: range[1],
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="filters">
|
||||
<div className="inner">
|
||||
<div className="filter-form">
|
||||
<form className="horizontal" onSubmit={handleSubmit}>
|
||||
<div className="input">
|
||||
<label>Interval</label>
|
||||
<select
|
||||
name="interval"
|
||||
onChange={handleIntervalChange}
|
||||
value={value.interval}
|
||||
>
|
||||
<option value="hour">Hour</option>
|
||||
<option value="day">Day</option>
|
||||
<option value="week">Week</option>
|
||||
<option value="month">Month</option>
|
||||
<option value="year">Year</option>
|
||||
<option value="custom">Custom</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{isCustomSelected && (
|
||||
<>
|
||||
<div className="input date-time">
|
||||
<label>From</label>
|
||||
<div>
|
||||
<DateTimeInput
|
||||
value={value.customFrom}
|
||||
onChange={(v) => setValue({ ...value, customFrom: v })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="input date-time">
|
||||
<label>To</label>
|
||||
<div>
|
||||
<DateTimeInput
|
||||
value={value.customTo}
|
||||
onChange={(v) => setValue({ ...value, customTo: v })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
<button>Apply</button>
|
||||
</form>
|
||||
</div>
|
||||
<div className="actions">
|
||||
{/*<label class="checkbox-label"><input type="checkbox"><span>auto-refresh</span></label>*/}
|
||||
<button>Refresh</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="shadow"></div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1,94 @@
|
|||
import { SensorInfo } from '@/api/sensors'
|
||||
import { getSensorValues } from '@/api/sensorValues'
|
||||
import { useEffect, useRef } from 'preact/hooks'
|
||||
import { useQuery } from 'react-query'
|
||||
import { FilterValue } from './Filters'
|
||||
|
||||
type Props = {
|
||||
sensor: SensorInfo
|
||||
filter: FilterValue
|
||||
}
|
||||
|
||||
export const Sensor = ({ sensor, filter }: Props) => {
|
||||
const bodyRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
const valuesQuery = {
|
||||
sensor: sensor.sensor,
|
||||
from: filter.customFrom,
|
||||
to: filter.customTo,
|
||||
}
|
||||
|
||||
const values = useQuery(['/sensor/values', valuesQuery], () =>
|
||||
getSensorValues(valuesQuery)
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
// TODO: These should be probably returned by server, could be outdated
|
||||
const from = filter.customFrom
|
||||
const to = filter.customTo
|
||||
const minValue = parseFloat(sensor.config.min)
|
||||
const maxValue = parseFloat(sensor.config.max)
|
||||
const customRange = !isNaN(minValue) && !isNaN(maxValue)
|
||||
|
||||
if (bodyRef.current && values.data) {
|
||||
window.Plotly.newPlot(
|
||||
bodyRef.current,
|
||||
[
|
||||
{
|
||||
...(sensor.config.graphType === 'line' && {
|
||||
type: 'scatter',
|
||||
mode: 'lines',
|
||||
}),
|
||||
...(sensor.config.graphType === 'points' && {
|
||||
type: 'scatter',
|
||||
mode: 'markers',
|
||||
}),
|
||||
...(sensor.config.graphType === 'lineAndPoints' && {
|
||||
type: 'scatter',
|
||||
mode: 'lines+markers',
|
||||
}),
|
||||
...(sensor.config.graphType === 'bar' && { type: 'bar' }),
|
||||
x: values.data.map((v) => new Date(v.timestamp * 1000)),
|
||||
y: values.data.map((v) => v.value),
|
||||
line: {
|
||||
width: 1,
|
||||
},
|
||||
},
|
||||
],
|
||||
{
|
||||
xaxis: { range: [from, to], type: 'date' },
|
||||
yaxis: {
|
||||
...(customRange && { range: [minValue, maxValue] }),
|
||||
...(sensor.config.unit && { ticksuffix: ` ${sensor.config.unit}` }),
|
||||
},
|
||||
margin: {
|
||||
l: 70,
|
||||
r: 20,
|
||||
b: 60,
|
||||
t: 20,
|
||||
pad: 5,
|
||||
},
|
||||
height: 300,
|
||||
},
|
||||
{
|
||||
responsive: true,
|
||||
}
|
||||
)
|
||||
}
|
||||
}, [values.data, sensor.config])
|
||||
|
||||
return (
|
||||
<div className="sensor">
|
||||
<div className="header">
|
||||
<div className="name">{sensor.config?.name ?? sensor.sensor}</div>
|
||||
<div className="actions">
|
||||
<button className="config">Config</button>
|
||||
<button className="refresh">Refresh</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="body">
|
||||
<div ref={bodyRef} />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1,104 @@
|
|||
import { setSensorConfig } from '@/api/sensor'
|
||||
import { SensorInfo } from '@/api/sensors'
|
||||
import { useState } from 'preact/hooks'
|
||||
|
||||
type Props = {
|
||||
sensor: SensorInfo
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
export const SensorSettings = ({ sensor, onClose }: Props) => {
|
||||
const [value, setValue] = useState(() => ({ ...sensor.config }))
|
||||
const [saving, setSaving] = useState(false)
|
||||
|
||||
const handleSave = async (e: Event) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
|
||||
if (saving) {
|
||||
return
|
||||
}
|
||||
|
||||
setSaving(true)
|
||||
|
||||
try {
|
||||
await Promise.all(
|
||||
Object.entries(value).map(([name, value]) =>
|
||||
setSensorConfig({ sensor: sensor.sensor, name, value })
|
||||
)
|
||||
)
|
||||
} catch (err) {
|
||||
// TODO: Better error handling
|
||||
alert(err)
|
||||
}
|
||||
|
||||
setSaving(false)
|
||||
|
||||
onClose()
|
||||
}
|
||||
|
||||
const handleChange = (e: Event) => {
|
||||
const target = e.target as HTMLSelectElement | HTMLInputElement
|
||||
|
||||
setValue({
|
||||
...value,
|
||||
[target.name]: target.value,
|
||||
})
|
||||
}
|
||||
|
||||
const preventPropagation = (e: Event) => {
|
||||
e.stopPropagation()
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="settings-modal${shown ? ' show' : ''}" onClick={onClose}>
|
||||
<div className="inner" onClick={preventPropagation}>
|
||||
<div className="body">
|
||||
<form onSubmit={handleSave}>
|
||||
<div className="input">
|
||||
<label>Sensor</label>
|
||||
<input value={sensor.sensor} disabled />
|
||||
</div>
|
||||
<div className="input">
|
||||
<label>Name</label>
|
||||
<input name="name" value={value.name} onChange={handleChange} />
|
||||
</div>
|
||||
<div className="input">
|
||||
<label>Type</label>
|
||||
<select
|
||||
name="graphType"
|
||||
value={value.graphType || 'line'}
|
||||
onChange={handleChange}
|
||||
>
|
||||
<option value="line">Line</option>
|
||||
<option value="points">Points</option>
|
||||
<option value="lineAndPoints">Line + Points</option>
|
||||
<option value="bar">Bar</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="input">
|
||||
<label>Unit</label>
|
||||
<input name="unit" value={value.unit} onChange={handleChange} />
|
||||
</div>
|
||||
<div className="input">
|
||||
<label>Min value</label>
|
||||
<input name="min" value={value.min} onChange={handleChange} />
|
||||
</div>
|
||||
<div className="input">
|
||||
<label>Max value</label>
|
||||
<input name="max" value={value.max} onChange={handleChange} />
|
||||
</div>
|
||||
|
||||
<div className="actions">
|
||||
<button className="cancel" onClick={onClose} type="button">
|
||||
Cancel
|
||||
</button>
|
||||
<button disabled={saving}>Save</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1,69 @@
|
|||
import { login } from '@/api/auth'
|
||||
import { getSensors } from '@/api/sensors'
|
||||
import { useAppContext } from '@/contexts/AppContext'
|
||||
import { useEffect, useState } from 'preact/hooks'
|
||||
|
||||
export const LoginPage = () => {
|
||||
const { setLoggedIn } = useAppContext()
|
||||
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [username, setUsername] = useState('')
|
||||
const [password, setPassword] = useState('')
|
||||
|
||||
const handleSubmit = async (e: Event) => {
|
||||
e.preventDefault()
|
||||
|
||||
if (loading) {
|
||||
return
|
||||
}
|
||||
|
||||
setLoading(true)
|
||||
|
||||
try {
|
||||
await login({ username, password })
|
||||
setLoggedIn(true)
|
||||
} catch {
|
||||
alert('Invalid username of password')
|
||||
}
|
||||
|
||||
setLoading(false)
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
getSensors()
|
||||
.then(() => {
|
||||
setLoggedIn(true)
|
||||
})
|
||||
.catch()
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<div className="login">
|
||||
<div className="box">
|
||||
<form onSubmit={handleSubmit}>
|
||||
<div className="input">
|
||||
<label>Username</label>
|
||||
<input
|
||||
type="text"
|
||||
name="username"
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.currentTarget.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="input">
|
||||
<label>Password</label>
|
||||
<input
|
||||
type="password"
|
||||
name="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.currentTarget.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button disabled={loading}>Login</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1,33 @@
|
|||
import { FilterInterval } from '@/pages/dashboard/components/Filters'
|
||||
|
||||
export const intervalToRange = (
|
||||
interval: FilterInterval,
|
||||
customFrom: Date,
|
||||
customTo: Date
|
||||
) => {
|
||||
switch (interval) {
|
||||
case 'hour': {
|
||||
return [new Date(Date.now() - 3600 * 1000), new Date()]
|
||||
}
|
||||
|
||||
case 'day': {
|
||||
return [new Date(Date.now() - 24 * 3600 * 1000), new Date()]
|
||||
}
|
||||
|
||||
case 'week': {
|
||||
return [new Date(Date.now() - 5 * 24 * 3600 * 1000), new Date()]
|
||||
}
|
||||
|
||||
case 'month': {
|
||||
return [new Date(Date.now() - 30 * 24 * 3600 * 1000), new Date()]
|
||||
}
|
||||
|
||||
case 'year': {
|
||||
return [new Date(Date.now() - 356 * 24 * 3600 * 1000), new Date()]
|
||||
}
|
||||
|
||||
case 'custom': {
|
||||
return [customFrom, customTo]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,17 @@
|
|||
export const splitDateTime = (v: Date | string) => {
|
||||
const d = new Date(v)
|
||||
|
||||
const date =
|
||||
d.getFullYear() +
|
||||
'-' +
|
||||
(d.getMonth() + 1).toString().padStart(2, '0') +
|
||||
'-' +
|
||||
d.getDate().toString().padStart(2, '0')
|
||||
|
||||
const time =
|
||||
d.getHours().toString().padStart(2, '0') +
|
||||
':' +
|
||||
d.getMinutes().toString().padStart(2, '0')
|
||||
|
||||
return [date, time]
|
||||
}
|
||||
|
|
@ -0,0 +1,74 @@
|
|||
{
|
||||
"compilerOptions": {
|
||||
/* Basic Options */
|
||||
"incremental": true, /* Enable incremental compilation */
|
||||
"target": "esnext", /* Specify ECMAScript target version: 'ES3' (default), 'ES5', 'ES2015', 'ES2016', 'ES2017', 'ES2018', 'ES2019' or 'ESNEXT'. */
|
||||
"module": "commonjs", /* Specify module code generation: 'none', 'commonjs', 'amd', 'system', 'umd', 'es2015', or 'ESNext'. */
|
||||
// "lib": [], /* Specify library files to be included in the compilation. */
|
||||
// "allowJs": true, /* Allow javascript files to be compiled. */
|
||||
// "checkJs": true, /* Report errors in .js files. */
|
||||
"jsx": "react-jsx", /* Specify JSX code generation: 'preserve', 'react-native', or 'react'. */
|
||||
"jsxImportSource": "preact",
|
||||
// "declaration": true, /* Generates corresponding '.d.ts' file. */
|
||||
// "declarationMap": true, /* Generates a sourcemap for each corresponding '.d.ts' file. */
|
||||
"sourceMap": true, /* Generates corresponding '.map' file. */
|
||||
// "outFile": "./", /* Concatenate and emit output to single file. */
|
||||
// "outDir": "./", /* Redirect output structure to the directory. */
|
||||
// "rootDir": "./", /* Specify the root directory of input files. Use to control the output directory structure with --outDir. */
|
||||
// "composite": true, /* Enable project compilation */
|
||||
// "tsBuildInfoFile": "./", /* Specify file to store incremental compilation information */
|
||||
// "removeComments": true, /* Do not emit comments to output. */
|
||||
// "noEmit": true, /* Do not emit outputs. */
|
||||
"importHelpers": true, /* Import emit helpers from 'tslib'. */
|
||||
// "downlevelIteration": true, /* Provide full support for iterables in 'for-of', spread, and destructuring when targeting 'ES5' or 'ES3'. */
|
||||
// "isolatedModules": true, /* Transpile each file as a separate module (similar to 'ts.transpileModule'). */
|
||||
|
||||
/* Strict Type-Checking Options */
|
||||
"strict": true, /* Enable all strict type-checking options. */
|
||||
// "noImplicitAny": true, /* Raise error on expressions and declarations with an implied 'any' type. */
|
||||
// "strictNullChecks": true, /* Enable strict null checks. */
|
||||
// "strictFunctionTypes": true, /* Enable strict checking of function types. */
|
||||
// "strictBindCallApply": true, /* Enable strict 'bind', 'call', and 'apply' methods on functions. */
|
||||
// "strictPropertyInitialization": true, /* Enable strict checking of property initialization in classes. */
|
||||
// "noImplicitThis": true, /* Raise error on 'this' expressions with an implied 'any' type. */
|
||||
// "alwaysStrict": true, /* Parse in strict mode and emit "use strict" for each source file. */
|
||||
|
||||
/* Additional Checks */
|
||||
// "noUnusedLocals": true, /* Report errors on unused locals. */
|
||||
// "noUnusedParameters": true, /* Report errors on unused parameters. */
|
||||
// "noImplicitReturns": true, /* Report error when not all code paths in function return a value. */
|
||||
// "noFallthroughCasesInSwitch": true, /* Report errors for fallthrough cases in switch statement. */
|
||||
|
||||
/* Module Resolution Options */
|
||||
// "moduleResolution": "node", /* Specify module resolution strategy: 'node' (Node.js) or 'classic' (TypeScript pre-1.6). */
|
||||
"baseUrl": "./", /* Base directory to resolve non-absolute module names. */
|
||||
"paths": {
|
||||
"@/*": ["src/*"],
|
||||
"@shared/*": ["../shared/src/*"]
|
||||
}, /* A series of entries which re-map imports to lookup locations relative to the 'baseUrl'. */
|
||||
// "rootDirs": [], /* List of root folders whose combined content represents the structure of the project at runtime. */
|
||||
// "typeRoots": [], /* List of folders to include type definitions from. */
|
||||
// "types": [], /* Type declaration files to be included in compilation. */
|
||||
"allowSyntheticDefaultImports": true, /* Allow default imports from modules with no default export. This does not affect code emit, just typechecking. */
|
||||
"esModuleInterop": true, /* Enables emit interoperability between CommonJS and ES Modules via creation of namespace objects for all imports. Implies 'allowSyntheticDefaultImports'. */
|
||||
// "preserveSymlinks": true, /* Do not resolve the real path of symlinks. */
|
||||
// "allowUmdGlobalAccess": true, /* Allow accessing UMD globals from modules. */
|
||||
|
||||
/* Source Map Options */
|
||||
// "sourceRoot": "", /* Specify the location where debugger should locate TypeScript files instead of source locations. */
|
||||
// "mapRoot": "", /* Specify the location where debugger should locate map files instead of generated locations. */
|
||||
// "inlineSourceMap": true, /* Emit a single file with source maps instead of having a separate file. */
|
||||
// "inlineSources": true, /* Emit the source alongside the sourcemaps within a single file; requires '--inlineSourceMap' or '--sourceMap' to be set. */
|
||||
|
||||
/* Experimental Options */
|
||||
// "experimentalDecorators": true, /* Enables experimental support for ES7 decorators. */
|
||||
// "emitDecoratorMetadata": true, /* Enables experimental support for emitting type metadata for decorators. */
|
||||
"lib": [ "DOM", "es2019" ]
|
||||
},
|
||||
"include": [
|
||||
"src/**/*"
|
||||
],
|
||||
"exclude": [
|
||||
"node_modules"
|
||||
]
|
||||
}
|
||||
|
|
@ -0,0 +1,23 @@
|
|||
import preact from '@preact/preset-vite'
|
||||
import path from 'path'
|
||||
import { defineConfig } from 'vite'
|
||||
|
||||
export default defineConfig({
|
||||
resolve: {
|
||||
alias: [
|
||||
{
|
||||
find: '@',
|
||||
replacement: path.join(path.resolve(__dirname), 'src'),
|
||||
},
|
||||
],
|
||||
},
|
||||
server: {
|
||||
proxy: {
|
||||
'/api': {
|
||||
target: 'http://localhost:8083',
|
||||
changeOrigin: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
plugins: [preact()],
|
||||
})
|
||||
Loading…
Reference in New Issue