большое обновление фронта

This commit is contained in:
VladislavOstapov 2024-01-03 12:38:37 +03:00
parent 4c94a7271a
commit a1f86691a4
9 changed files with 333 additions and 200 deletions

View File

@ -19,5 +19,7 @@ from . import views
urlpatterns = [ urlpatterns = [
path('', views.view_index, name='index'), path('', views.view_index, name='index'),
path('fetch/stats', views.view_stats, name='fetch-stats'),
path('fetch/tank-chart', views.view_tank_chart, name='fetch-tank-chart'),
# path('methods/<str:method_name>', views.call_method, name='call_method') # path('methods/<str:method_name>', views.call_method, name='call_method')
] ]

View File

@ -1,8 +1,30 @@
import os
from django.http import HttpResponse
from django.shortcuts import render from django.shortcuts import render
from django.db.models import Manager from django.db.models import Manager
# Create your views here. # только для тестирования!
import requests
TEST_BASE_FETCH = "https://test.wawaa.ru/dev-fetch.php"
def view_index(request): def view_index(request):
return render(request, 'index.html') return render(request, 'index.html')
def view_stats(request):
# только для тестирования!
res = requests.get(TEST_BASE_FETCH + "?stats", headers={'Authorization': os.getenv("TEST_AUTH")})
response = HttpResponse(res.content)
response.headers["Content-type"] = response.headers["Content-type"]
return response
def view_tank_chart(request):
# только для тестирования!
res = requests.get(TEST_BASE_FETCH + "?tank_chart", headers={'Authorization': os.getenv("TEST_AUTH")})
response = HttpResponse(res.content)
response.headers["Content-type"] = response.headers["Content-type"]
return response

View File

@ -28,7 +28,7 @@ PROJECT_ROOT = os.path.dirname(__file__)
SECRET_KEY = os.getenv('DJANGO_SECRET') SECRET_KEY = os.getenv('DJANGO_SECRET')
ALLOWED_HOSTS = ['localhost'] ALLOWED_HOSTS = ['localhost', '10.8.0.2']
# CSRF_TRUSTED_ORIGINS = ['https://ospaz.wawaa.ru'] # CSRF_TRUSTED_ORIGINS = ['https://ospaz.wawaa.ru']
# HTTPS settings https://docs.djangoproject.com/en/5.0/topics/security/ # HTTPS settings https://docs.djangoproject.com/en/5.0/topics/security/

View File

@ -16,10 +16,8 @@ Including another URLconf
""" """
from django.contrib import admin from django.contrib import admin
from django.urls import path, include from django.urls import path, include
from django.contrib.staticfiles.views import serve from django.contrib.staticfiles.views import serve
import index.urls
urlpatterns = [ urlpatterns = [
path('', include('index.urls')), path('', include('index.urls')),

View File

@ -1,27 +1,26 @@
/* TODO исправить стили, тут верхней навигации вообще нет */
/* ========== THEME ========== */ /* ========== THEME ========== */
body { body {
--text-color: #111; --text-color: #262626;
--brand-color: #231765; --text-color2: #3d3d3d;
--bkg-color-blue: #0066e3;
--bkg-color: #fff; --brand-bg: #EDF3FE;
--bkg-color2: #ccc; --brand-text: #5488F7;
--bkg-color3: #aaa;
--bg-color: #FEFEFE;
--bg-selected: #F1F1F1;
} }
@media (prefers-color-scheme: dark) { @media (prefers-color-scheme: dark) {
/* defaults to dark theme */ /* defaults to dark theme */
body { body {
--text-color: #eee; --text-color: #eee;
--brand-color: #654dea; --text-color2: #bbb;
--bkg-color-blue: #003aac;
--bkg-color: #121212; --brand-bg: #393E50;
--bkg-color2: #202020; --brand-text: #5F93F3;
--bkg-color3: #353435;
--bg-color: #2d2c33;
--bg-selected: #424248;
} }
} }
@ -30,50 +29,28 @@ body {
color: var(--text-color); color: var(--text-color);
} }
body { *, *::before, *::after {
background: var(--bkg-color); box-sizing: border-box;
} }
.page-header { body {
text-align: center; background: var(--bg-color);
margin: 1em 3em; margin: 0; /* браузеры зачем-то ставят свое значение */
}
#content {
margin: 0.2em;
} }
/* ========== MAIN STYLES ========== */ /* ========== MAIN STYLES ========== */
#header-wrapper { header > h1 {
display: flex; text-align: center;
margin: 1em; background-color: var(--brand-bg);
padding: 0.5em;
} }
#header-wrapper * { header * {
color: var(--brand-color); color: var(--brand-text);
} }
header {
display: flex;
flex-direction: row;
flex-wrap: nowrap;
margin: 0 auto;
}
header > * {
margin: auto 0.5em;
text-decoration: none;
font-size: medium;
}
header > div > * {
display: block;
margin: 0.1em 0;
}
#logo-text {
font-weight: bolder;
font-size: xx-large;
}
#logo-image {
width: 50px;
height: 50px;
}

View File

@ -1,2 +1,2 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:se="http://svg-edit.googlecode.com" xmlns:xlink="http://www.w3.org/1999/xlink" xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:cc="http://creativecommons.org/ns#" xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#" xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape" width="85" height="80" style=""> <title>favicon</title> <svg xmlns="http://www.w3.org/2000/svg" width="85" height="85" style=""><title>favicon</title>
<rect id="backgroundrect" width="100%" height="100%" x="0" y="0" fill-opacity="0.0" stroke="none" style="" class=""/> <g class="currentLayer" style=""><title>Layer 1</title><rect fill="#4a90d6" stroke="#222222" stroke-width="2" stroke-linejoin="round" stroke-dashoffset="" fill-rule="nonzero" id="svg_38" x="77.39605712890625" y="107.25747680664062" width="7" height="0" style="color: rgb(0, 0, 0);" class=""/><path fill="#4a90d6" stroke="#222222" stroke-width="2" stroke-linejoin="round" stroke-dashoffset="" fill-rule="nonzero" marker-start="" marker-mid="" marker-end="" id="svg_42" d="M4.913043206854184,73.1181132105101 L23.991979764689916,39.72997423429874 L43.07091632252451,73.1181132105101 L4.913043206854184,73.1181132105101 z" style="color: rgb(0, 0, 0);" class=""/><path fill="#4a90d6" stroke="#222222" stroke-width="2" stroke-linejoin="round" stroke-dashoffset="" fill-rule="nonzero" marker-start="" marker-mid="" marker-end="" d="M43.558975285215695,73.08701234133133 L62.63791184305143,39.698873365119965 L81.71684840088602,73.08701234133133 L43.558975285215695,73.08701234133133 z" style="color: rgb(0, 0, 0);" class="" id="svg_47"/><path fill="#4a90d6" stroke="#222222" stroke-width="2" stroke-linejoin="round" stroke-dashoffset="" fill-rule="nonzero" marker-start="" marker-mid="" marker-end="" d="M24.25514847628625,39.17074458868393 L43.33408503412198,5.782605612472565 L62.41302159195658,39.17074458868393 L24.25514847628625,39.17074458868393 z" style="color: rgb(0, 0, 0);" class="" id="svg_46"/><path fill="#4a90d6" stroke="#222222" stroke-width="2" stroke-linejoin="round" stroke-dashoffset="" fill-rule="nonzero" marker-start="" marker-mid="" marker-end="" id="svg_48" d="M33.91763093443063,55.92877816603246 L43.29425964116126,40.29240521591228 L52.6708883478924,55.92877816603246 L33.91763093443063,55.92877816603246 z" style="color: rgb(0, 0, 0);" class=""/></g></svg> <rect id="backgroundrect" width="100%" height="100%" x="0" y="0" fill-opacity="0.0" stroke="none" style="" class=""/> <g class="currentLayer" style=""><title>Layer 1</title><rect fill="#4a90d6" stroke="#222222" stroke-width="2" stroke-linejoin="round" stroke-dashoffset="" fill-rule="nonzero" id="svg_38" x="77.39605712890625" y="107.25747680664062" width="7" height="0" style="color: rgb(0, 0, 0);" class=""/><path fill="#4a90d6" stroke="#222222" stroke-width="2" stroke-linejoin="round" stroke-dashoffset="" fill-rule="nonzero" marker-start="" marker-mid="" marker-end="" id="svg_42" d="M4.913043206854184,73.1181132105101 L23.991979764689916,39.72997423429874 L43.07091632252451,73.1181132105101 L4.913043206854184,73.1181132105101 z" style="color: rgb(0, 0, 0);" class=""/><path fill="#4a90d6" stroke="#222222" stroke-width="2" stroke-linejoin="round" stroke-dashoffset="" fill-rule="nonzero" marker-start="" marker-mid="" marker-end="" d="M43.558975285215695,73.08701234133133 L62.63791184305143,39.698873365119965 L81.71684840088602,73.08701234133133 L43.558975285215695,73.08701234133133 z" style="color: rgb(0, 0, 0);" class="" id="svg_47"/><path fill="#4a90d6" stroke="#222222" stroke-width="2" stroke-linejoin="round" stroke-dashoffset="" fill-rule="nonzero" marker-start="" marker-mid="" marker-end="" d="M24.25514847628625,39.17074458868393 L43.33408503412198,5.782605612472565 L62.41302159195658,39.17074458868393 L24.25514847628625,39.17074458868393 z" style="color: rgb(0, 0, 0);" class="" id="svg_46"/><path fill="#4a90d6" stroke="#222222" stroke-width="2" stroke-linejoin="round" stroke-dashoffset="" fill-rule="nonzero" marker-start="" marker-mid="" marker-end="" id="svg_48" d="M33.91763093443063,55.92877816603246 L43.29425964116126,40.29240521591228 L52.6708883478924,55.92877816603246 L33.91763093443063,55.92877816603246 z" style="color: rgb(0, 0, 0);" class=""/></g></svg>

Before

Width:  |  Height:  |  Size: 2.3 KiB

After

Width:  |  Height:  |  Size: 2.0 KiB

160
static/js/index-main.js Normal file
View File

@ -0,0 +1,160 @@
// скрипт страницы index, на которой отображаются графики
// интервал обновления статуса в миллисекундах
const RELOAD_STATS_INTERVAL = 10000
// интервал обновления графика - 1% показываемого времени (2 недели)
// const RELOAD_CHART_INTERVAL = (3600 * 1000 * 24 * 14) / 100
// const RELOAD_CHART_INTERVAL = (3600 * 1000 * 24) / 100
const RELOAD_CHART_INTERVAL = 30000
const DATETIME_FORMAT = "DD MMM YYYY HH:mm:ss"
function unpackBits(num, desc) {
let out = ""
for (let i = 0; i < desc.length; i++) {
if (desc[i] !== null) {
if ((num & (1 << i)) !== 0) {
// бит установлен
if (out.length === 0) {
out = desc[i]
} else {
out += " + "
out += desc[i]
}
}
}
}
return out
}
/**
* Функция для линейной аппроксимации набора точек. Для заданных точек находит коэфицент a из уравнения y=ax+b.
* Уравнение подбирается при помощи метода наименьших квадратов.
* @param dataset набор точек в виде [[timestamp, val], [timestamp, val], ...], где timestamp - значение по X, val - значение по Y
* @returns {number}, коэфицент a
*/
function approximateWithTimestamps(dataset) {
// для точных расчетов нужно сместить timestamp
const timestamp_offset = dataset[0][0]
// сумма (x[i] * y[i])
let sum_x_mul_y = 0
for (let i = 0; i < dataset.length; i++) {
sum_x_mul_y += (dataset[i][0] - timestamp_offset) * dataset[i][1]
}
// сумма всех x[i]
let sum_x = 0;
for (let i = 0; i < dataset.length; i++) {
sum_x += dataset[i][0] - timestamp_offset
}
// сумма всех y[i]
let sum_y = 0;
for (let i = 0; i < dataset.length; i++) {
sum_y += dataset[i][1]
}
// сумма всех x[i]^2
let sum_x_mul_x = 0;
for (let i = 0; i < dataset.length; i++) {
sum_x_mul_x += Math.pow(dataset[i][0] - timestamp_offset, 2)
}
// вычисление коэфицента a из формулы y=ax+b
// нам нужен только он
return (dataset.length * sum_x_mul_y - sum_x * sum_y) / (sum_x_mul_x - Math.pow(sum_x, 2))
}
async function makeRequest(url) {
let response = await fetch(url)
if (response.status !== 200) {
console.log('fetch(' + url + ') failed. Status Code: ' + response.status);
return null;
}
return await response.json()
}
async function loadAllData() {
return await makeRequest('/fetch/all')
}
async function loadChartData() {
return (await makeRequest('/fetch/tank-chart'))['tank_chart']
}
async function loadLastUpdates() {
return (await makeRequest('/fetch/stats'))['stats']
}
async function updateStatus() {
let dataset = await loadLastUpdates()
// последнее обновление
document.getElementById("tank-last-update").innerHTML = moment(new Date(dataset['tank']['last_update'] * 1000)).format(DATETIME_FORMAT)
document.getElementById("pump-last-update").innerHTML = moment(new Date(dataset['pump']['last_update'] * 1000)).format(DATETIME_FORMAT)
//<p>Уровень воды <span id="tank-level-dir"></span></p>
const last_radar_values = dataset['tank']['last_radar_values']
if (last_radar_values.length === 0) {
document.getElementById("tank-level-dir").innerHTML = "(?)";
} else {
document.getElementById("tank-level-dir").innerHTML = approximateWithTimestamps(last_radar_values) < 0 ? '↘' : '↗';
}
//<p>Текущий уровень воды: <span id="tank-level-now"></span>%</p>
document.getElementById("tank-level-now").innerHTML = dataset['tank']['last_percentage']
//<p>Текущее значение с радара: <span id="tank-raw-now"></span></p>
document.getElementById("tank-raw-now").innerHTML = dataset['tank']['last_radar']
//<p>Статус: <span id="tank-status"></span></p>
const shur_status_bits = ['нужна вода', 'поплавок нижний', 'поплавок верхний', 'поплавок аварийный']
document.getElementById("tank-status").innerHTML = unpackBits(dataset['tank']['status_reg'], shur_status_bits) + " (" + dataset['tank']['status_reg'] + ")";
//<p>Частота ПЧ: <span id="pump-vfd-freq"></span>Гц</p>
document.getElementById("pump-vfd-freq").innerHTML = dataset['pump']['vfd_freq']
//<p>Ток ПЧ: <span id="pump-vfd-curr"></span>А</p>
document.getElementById("pump-vfd-curr").innerHTML = dataset['pump']['vfd_curr']
//<p>Ошибка ПЧ: <span id="pump-vfd-error"></span></p>
document.getElementById("pump-vfd-error").innerHTML = dataset['pump']['vfd_err']
//<p>Регистр аварий: <span id="pump-alarms"></span></p>
document.getElementById("pump-alarms").innerHTML = dataset['pump']['alarms']
//<p>Состояние КА: <span id="pump-stage"></span></p>
document.getElementById("pump-stage").innerHTML = dataset['pump']['pump_stage']
//<p>Текущий расход: <span id="pump-flow-meter"></span>м³</p>
document.getElementById("pump-flow-meter").innerHTML = dataset['pump']['flow_meter']
}
// состояния конченого автомата
// $states = [
// 0 => "отключен",
// 2 => "инициализация: установка задвижек в начальное состояние",
// 21 => "инициализация: закрытие задвижек 23.5 и 23.6",
// 31 => "инициализация: открытие задвижек 23.5 и 23.6",
// 99 => "<span class=\"value-bad\">авария</span>",
//
// 100 => "ожидание сигнала на перекачку воды",
//
// 102 => "запуск: открытие задвижки 23.6",
// 110 => "запуск: ожидание сигнала от датчика уровня поз.36",
// 121 => "запуск: открытие задвижек насоса",
// 131 => "запуск: закрытие задвижки 23.6",
// 141 => "запуск: пуск ПЧ",
//
// 200 => "<span class=\"value-good\">работает</span>",
//
// 202 => "остановка: закрытие задвижек 23.3 и 23.4",
// 211 => "остановка: ожидание остановки ПЧ",
// 221 => "остановка: перевод запорной арматуры в исходное состояние",
// 231 => "остановка: работа компрессора",
// 235 => "остановка: сброс конденсата клапанами 25.*"
// ];

View File

@ -7,6 +7,7 @@
<title> {% block title %} Мониторинг резервуара {% endblock %} </title> <title> {% block title %} Мониторинг резервуара {% endblock %} </title>
{% load static %} {% load static %}
<link rel="stylesheet" type="text/css" href="{% static 'css/style.css' %}"> <link rel="stylesheet" type="text/css" href="{% static 'css/style.css' %}">
{% block head %} {% endblock %}
{% block styles %} {% endblock %} {% block styles %} {% endblock %}
</head> </head>
<body> <body>

View File

@ -1,164 +1,137 @@
{% extends 'base.html' %} {% extends 'base.html' %}
{% load static %}
{% block head %}
<script src="{% static 'js/chart-4.4.1.umd.js' %}"></script>
<script src="{% static 'js/moment-2.js' %}"></script>
<script src="{% static 'js/chartjs-adapter-moment.js' %}"></script>
<script src="{% static 'js/index-main.js' %}"></script>
{% endblock %}
{% block styles %} {% block styles %}
<style> <style>
.content-wrapper {
display: flex;
flex-direction: row;
flex-wrap: wrap;
}
</style> .content-block {
margin: 0.5em;
}
#canvas-wrapper {
width: 100%;
}
.value-good {
color: green;
}
.value-bad {
color: red;
}
</style>
{% endblock %} {% endblock %}
{% block header %} {% block header %}
<h1 class="page-header"> Мониторинг водозаборного узла </h1> <h1> Мониторинг водозаборного узла </h1>
{% if user.is_superuser %} {% if user.is_superuser %}
<a href="/admin">Админка</a> <a href="/admin">Админка</a>
{% endif %} {% endif %}
{% endblock %} {% endblock %}
{% block content %} {% block content %}
<script>
function getAccessToken(new_value) {
if (new_value === undefined || new_value === null || new_value === "") {
let res = localStorage.getItem("access_token")
if (res === null) {
return ""
}
return res
} else {
console.log(`Storing ${new_value} as token`)
localStorage.setItem("access_token", new_value)
document.getElementById('current_access_token').innerText = new_value
return new_value
}
}
async function sendRequest(method, params) {
let url = `/methods/${method}`
if (params !== undefined && params !== null) {
url += "?" + new URLSearchParams(params)
}
return await fetch(url)
}
async function makeRequest(view, method, inputs) { <div>
let params = {} <div class="content-wrapper">
for (let k in inputs) { <div class="content-block">
let element = document.getElementById(inputs[k]) <h2>Состояние насосной станции</h2>
const name = element.name <p>Последнее обновление: <span id="pump-last-update"></span></p>
let val = element.value <p>Частота ПЧ: <span id="pump-vfd-freq"></span>Гц</p>
if (name === "access_token") { <p>Ток ПЧ: <span id="pump-vfd-curr"></span>А</p>
val = getAccessToken(val) <p>Ошибка ПЧ: <span id="pump-vfd-error"></span></p>
} <p>Регистр аварий: <span id="pump-alarms"></span></p>
if (val.length > 0) <p>Состояние КА: <span id="pump-stage"></span></p>
params[name] = val <p>Текущий расход: <span id="pump-flow-meter"></span>м³</p>
}
let res = await sendRequest(method, params)
const text = await res.text()
document.getElementById(view).innerText = text
// чтобы запоминался токен
try {
let j = JSON.parse(text)
getAccessToken(j["response"]["access_token"])
} catch (e) {}
}
</script>
{% for method in api_methods %}
<div class="method-div">
<details>
<summary>{{ method.name }}</summary>
<div>
<h3>Описание</h3>
<p>
{{ method.doc | safe }}
</p>
<h3>Параметры</h3>
{% if method.params %}
<div class="table-wrapper"><table>
<tr>
<th>Название</th>
<th>Тип</th>
<th>Описание</th>
<th>Обязательный</th>
</tr>
{% for param in method.params %}
<tr>
<td>{{ param.name }}</td>
<td>{{ param.type }}</td>
<td>{{ param.description | safe }}</td>
<td>{{ param.required }}</td>
</tr>
{% endfor %}
</table></div>
{% else %}
<p>
Этот метод не принимает параметров.
</p>
{% endif %}
<h3>Результат</h3>
<p>
{{ method.returns | safe }}
</p>
<p>
Ссылка на метод (без параметров): <a href="/methods/{{ method.name }}">{{ method.name }}</a>
</p>
<details>
<summary>Конструктор</summary>
<div class="constructor-wrapper" id="view-{{ method.name }}">
<div class="constructor-fields">
<div style="">
<h3>Параметры</h3>
<hr>
</div>
{% if method.params %}
{% for param in method.params %}
<div class="constructor-param">
<label for="param-{{ method.name }}-{{ param.name }}">{{ param.name }}</label>
<input type="text" name="{{ param.name }}" id="param-{{ method.name }}-{{ param.name }}">
</div>
{% endfor %}
{% else %}
<div class="constructor-param">
<p>
Этот метод не принимает параметров.
</p>
</div>
{% endif %}
<div class="constructor-param">
<button onclick="makeRequest('result-{{ method.name }}', '{{ method.name }}',
[{% for param in method.params %}'param-{{ method.name }}-{{ param.name }}', {% endfor %}])">Выполнить</button>
</div>
</div>
<div class="constructor-result">
<h3>Результат</h3>
<hr>
<pre id="result-{{ method.name }}"></pre>
</div>
</div>
</details>
</div>
</details>
</div> </div>
{% endfor %} <div class="content-block">
<h2>Состояние резервуара</h2>
<div style="text-align: center; background: var(--bkg-color2); margin: 0; margin-top: 3em; padding: 2em; overflow-wrap: break-word;"> <p>Последнее обновление: <span id="tank-last-update"></span></p>
Перейти в <a href="/admin">админку</a>. <p>Текущий уровень воды: <span id="tank-level-now"></span>%</p>
<div> <p>Текущее значение с радара: <span id="tank-raw-now"></span></p>
Текущий токен: <i id="current_access_token"></i><br><a onclick="localStorage.clear(); document.getElementById('current_access_token').innerText = ''">Сбросить</a> <p>Уровень воды <span id="tank-level-dir"></span></p>
<p>Статус: <span id="tank-status"></span></p>
</div> </div>
</div> </div>
<div class="content-block chart">
<h2> Уровень воды в резервуаре, %</h2>
<div id="canvas-wrapper"><canvas id="water_level"></canvas></div>
</div>
</div>
<script> <script>
window.onload = (event) => { moment.locale('ru')
const at = localStorage.getItem("access_token")
if (at !== null) { let bodyStyles = window.getComputedStyle(document.body);
document.getElementById('current_access_token').innerText = at
const chart_dataset = {
label: "Резервуар",
color: bodyStyles.getPropertyValue('--brand-text'),
data: [],
pointRadius: 0
}
Chart.defaults.color = bodyStyles.getPropertyValue('--text-color2');
const chart = new Chart("water_level", {
type: "line",
data: {
datasets: [chart_dataset]
},
options: {
responsive: true,
legend: { display: false },
scales: {
y: { min: 0, max: 100 },
x: {
type: 'time',
time: {
unit: 'day'
},
scaleLabel: {
labelString: 'Timestamp'
}
}
} }
}; }
</script> });
document.addEventListener("DOMContentLoaded", (event) => {
// запуск обновления статистики
updateStatus().then(() => {
setInterval(() => {
updateStatus().then()
}, RELOAD_STATS_INTERVAL)
})
function updateChart(data) {
chart_dataset.data = data.map((e) => {
return {x: new Date(e[0] * 1000), y: e[1]}
})
chart.update();
}
loadChartData().then((data) => {
updateChart(data)
setInterval(() => {
loadChartData().then((d) => {
updateChart(d)
})
}, RELOAD_CHART_INTERVAL)
})
});
</script>
{% endblock %} {% endblock %}