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 charset="UTF-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>Sensors</title>
|
<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="https://cdn.plot.ly/plotly-2.14.0.min.js"></script>
|
||||||
<script src="js/lighterhtml@4.2.0.min.js"></script>
|
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="sensors-container"></div>
|
<div id="application"></div>
|
||||||
<script src="js/index.js"></script>
|
<script type="module" src="/src/index.tsx"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</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;
|
box-sizing: border-box;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.login {
|
||||||
|
width: 300px;
|
||||||
|
margin: 2rem auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login .box {
|
||||||
|
padding: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
#sensors-container {
|
#sensors-container {
|
||||||
height: 100%;
|
height: 100%;
|
||||||
overflow: auto;
|
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 {
|
.sensor {
|
||||||
background: #fff;
|
background: #fff;
|
||||||
border-radius: 0.5rem;
|
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