Reworked client to preact

This commit is contained in:
Jan Zípek 2022-08-21 22:27:31 +02:00
parent a26e81f22d
commit fe204aa77d
Signed by: kamen
GPG Key ID: A17882625B33AC31
28 changed files with 11485 additions and 409 deletions

61
client/.eslintrc.js Normal file
View File

@ -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',
},
},
}

1
client/.gitignore vendored Normal file
View File

@ -0,0 +1 @@
node_modules

5
client/.prettierrc.js Normal file
View File

@ -0,0 +1,5 @@
module.exports = {
useTabs: true,
semi: false,
singleQuote: true,
}

View File

@ -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" />
<script src="https://cdn.plot.ly/plotly-2.14.0.min.js"></script>
<script src="js/lighterhtml@4.2.0.min.js"></script>
<!-- TODO: This should be loaded locally -->
<script src="https://cdn.plot.ly/plotly-2.14.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>

View File

@ -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

10608
client/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

31
client/package.json Normal file
View File

@ -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"
}
}

15
client/src/Root.tsx Normal file
View File

@ -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>
)
}

20
client/src/api/auth.ts Normal file
View File

@ -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'
)

45
client/src/api/request.ts Normal file
View File

@ -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
}

20
client/src/api/sensor.ts Normal file
View File

@ -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'
)

View File

@ -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)}`
)

View File

@ -0,0 +1,8 @@
import { request } from './request'
export type SensorInfo = {
sensor: string
config: Record<string, string>
}
export const getSensors = () => request<SensorInfo[]>('/api/sensors')

View File

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

View File

@ -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} />
</>
)
}

View File

@ -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
}

7
client/src/index.tsx Normal file
View File

@ -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')!)

View File

@ -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 />}
</>
)
}

View File

@ -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>
</>
)
}

View File

@ -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>
)
}

View File

@ -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>
)
}

View File

@ -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>
)
}

View File

@ -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>
)
}

View File

@ -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]
}
}
}

View File

@ -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]
}

74
client/tsconfig.json Normal file
View File

@ -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"
]
}

23
client/vite.config.js Normal file
View File

@ -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()],
})