fixed default network

This commit is contained in:
2026-06-29 15:06:48 +03:00
parent 980608a279
commit e9e2cbbfec
18 changed files with 1227 additions and 138 deletions
+2
View File
@@ -17,6 +17,8 @@ set(SOURCES
src/buttonsmodel.h
src/buttoncontroller.cpp
src/buttoncontroller.h
src/responsecheck.cpp
src/responsecheck.h
src/settingscontainer.cpp
src/settingscontainer.h
src/systeminfo.cpp
Binary file not shown.
Binary file not shown.
BIN
View File
Binary file not shown.
+281
View File
@@ -0,0 +1,281 @@
# Настройка ButtonTask на готовом устройстве
Инструкция для панели, которая **уже установлена и запущена**. Процесс установки и деплоя здесь не описан.
## Исходное состояние
| Параметр | Значение по умолчанию |
|----------|------------------------|
| IP устройства | `192.168.1.60` (статический, `eth0`) |
| Веб-конфигуратор | `http://192.168.1.60:8080` |
| Пароль веба и панели | `admin` |
| Кнопка на экране | «Вызов клининга» |
| URL кнопки | `http://192.168.1.55` (адрес сервиса в вашей сети) |
| Срабатывание | удержание ~800 мс |
Убедитесь, что ПК в той же подсети (`192.168.1.x`) и панель отвечает по IP.
---
## Два способа настройки
| Что настраивать | Где удобнее |
|-----------------|-------------|
| URL, тексты, иконки, цвет, удержание/клик | Панель **или** веб |
| **Правила JSON** (расширенная проверка ответа), polling | Только **веб** |
| Внешний вид (фон, сетка, обводка), сеть, журнал, OTA | Только **веб** |
| Включить/выключить расширенную проверку (без правил) | Панель (переключатель) + веб (правила) |
Изменения из веба и с панели пишутся в один файл `/opt/buttontask/config/config.json` и подхватываются приложением автоматически.
---
## Веб-конфигуратор
### Вход
1. Откройте в браузере: **http://192.168.1.60:8080**
2. Пароль: **admin** (сменить — вкладка «Безопасность»).
### Вкладка «Кнопки»
Список кнопок. Для каждой: **✎ Редактировать**, **✕ Удалить**. Кнопка **+ Добавить** — новая кнопка.
Порядок в списке меняется перетаскиванием (drag-and-drop).
#### Диалог редактирования кнопки
| Поле | Назначение |
|------|------------|
| Подпись | Текст под кнопкой на панели |
| Иконка | Файл из `/opt/buttontask/icons` (загрузка — вкладка «Внешний вид») |
| Метод | `GET` или `POST` |
| URL | Куда уходит запрос, например `http://192.168.1.55/api/call` |
| Текст успеха | Сообщение при **успехе** (не подставляйте сюда URL) |
| Текст ошибки | Сообщение при ошибке |
| Текст ожидания | Пока идёт запрос или polling (`...` по умолчанию) |
| Цвет кнопки | Цвет круга на панели |
| Срабатывание | **Удержание** (нужно держать палец) или **Клик** |
| Длительность удержания | 200–3000 мс |
После **Сохранить** страница перезагрузится; на панели изменения видны сразу.
---
## Простая кнопка (только HTTP)
Если галочка **«Расширенная проверка ответа (JSON)»** **выключена**:
- успех = HTTP-код **меньше 400** (ответ 200, 201…);
- ошибка = код 400 и выше или сеть недоступна;
- тело ответа **не разбирается**.
Подходит, когда сервер просто отвечает `200 OK` без важного JSON.
**Пример — дефолтная «Вызов клининга»:**
- URL: `http://192.168.1.55`
- Текст успеха: `Вызов отправлен`
- Текст ошибки: `Ошибка`
- Расширенная проверка: выкл.
---
## Расширенная проверка JSON
Включите **«Расширенная проверка ответа (JSON)»**, если сервер возвращает JSON и результат нужно понимать по полям в теле, а не только по коду HTTP.
### Как это работает на панели
1. Нажатие → белая обводка / свечение (**ожидание**).
2. По правилам:
- **Принято** → зелёная обводка;
- **Ошибка** → красная;
- **Ожидание** → жёлтое пульсирующее свечение, повтор запросов (polling).
3. Через несколько секунд подпись и обводка гаснут (по умолчанию ~5 с).
Пока кнопка занята (статус не «готов»), повторное нажатие блокируется.
### Блок настроек в вебе
| Параметр | Описание |
|----------|----------|
| Требовать HTTP < 400 | При выкл. смотрят только правила JSON, даже если HTTP 500 |
| Если ни одно правило не подошло | Что делать с неизвестным ответом (обычно **Ошибка**) |
| **Правила** | Таблица: поле → значение → **результат** → сообщение |
| Polling | Интервал, макс. попыток, общий таймаут — только для результата **Ожидание** |
### Строка правила
Колонки (слева направо):
1. **Поле** — имя в JSON, точка для вложенности (`data.status`).
2. **Условие** — «равно» или «содержит».
3. **Значение** — с чем сравнивать.
4. **Результат****Принято** / **Ошибка** / **Ожидание** ← важнейшая колонка.
5. **Сообщение** — необязательно; иначе берётся текст успеха/ошибки/ожидания.
> **Частая ошибка:** для `success = error` в колонке результата оставили **Принято** — панель станет зелёной с текстом «Сбой». Для ошибки нужно **Ошибка**, для `pending` — **Ожидание**.
### Пример: ответ с полем `success`
Сервер отвечает: `{"success":"ok"}` / `"pending"` / `"error"`.
| поле | равно | результат | сообщение |
|------|-------|-----------|-----------|
| `success` | `ok` | Принято | Готово |
| `success` | `pending` | **Ожидание** | Ждём… |
| `success` | `error` | **Ошибка** | Сбой |
Polling: интервал **2000** мс, попыток **10**, таймаут **60000** мс.
### Пример: вложенный JSON
URL: `http://сервер/nested?status=ready`
Ответ: `{"data":{"status":"ready"}}`
| поле | равно | результат | сообщение |
|------|-------|-----------|-----------|
| `data.status` | `ready` | Принято | Принято |
| `data.status` | `busy` | Ожидание | Занято |
| `data.status` | `fail` | Ошибка | Отказ |
### Пример: эндпоинт `/state`
URL: `http://сервер/state?phase=error`
В **теле** JSON поле называется `success`, не `phase`:
```json
{"success":"error","ts":...}
```
Правило: поле **`success`**, значение `error`, результат **Ошибка**.
### Polling
Используйте, когда первые ответы приходят с `"success":"pending"`, а готовность — с `"success":"ok"`.
- Каждые `intervalMs` мс повторяется тот же URL.
- Останавливается при **Принято** / **Ошибка**, по лимиту попыток или таймауту.
- Перед повторным тестом на тестовом сервере сбросьте счётчик: `GET .../reset`.
---
## Настройка на самой панели
### Открыть настройки
1. Нажмите **шестерёнку** (правый нижний угол).
2. Введите пароль (**admin** по умолчанию).
### Разделы меню
| Раздел | Возможности |
|--------|-------------|
| **Кнопки** | Список, добавить, редактировать, удалить, порядок (≡ перетащить) |
| **Расположение** | Сетка / список, число колонок, отступы, подписи |
| **Фон** | Градиент, цвет, картинка |
| **Обводка** | Цвета и толщина OK/ошибка, свечение |
| **Сеть** | Просмотр интерфейсов; смена IP — удобнее в вебе |
| **Обновление** | Текущая версия ПО (OTA — в основном через веб) |
| **Сменить пароль** | Пароль для панели и веба |
### Редактор кнопки на панели
Те же поля, что в вебе: подпись, иконка, метод, URL, тексты, цвет, удержание/клик.
Переключатель **«Расширенная проверка»** только включает/выключает режим. **Правила JSON задаются в веб-конфигураторе** (подсказка на экране).
### Нажатие кнопки на панели
- **Удержание** (по умолчанию): удерживайте, пока заполнится кольцо (~800 мс), затем отпустите — уйдёт запрос.
- **Клик**: одно касание.
---
## Вкладки веба (кратко)
### Внешний вид
- **Расположение** — сетка/список, колонки, отступы.
- **Фон** — тип, цвета, анимация, фоновое изображение.
- **Обводка** — цвета OK/ERR, толщина, радиус свечения.
- **Иконки** — загрузка PNG/JPG/SVG в папку иконок.
### Сеть
Состояние `eth0`, режим DHCP/статический IP, шлюз, DNS. После **Применить** настройки сохраняются в `network.json` и применяются скриптом сети.
### Система
- Нагрузка CPU/RAM, время.
- Доступ: ping (ICMP), SSH, порт веба.
- **Журнал нажатий** — какая кнопка, URL, результат (`OK` / `ERR` / `WAIT`), время в мс.
Полезно при отладке: если везде `OK 200` без `WAIT`, проверьте колонку **результат** в правилах.
### Обновление
Загрузка пакета `.tar.gz` (бинарник + веб). Конфиг и иконки в `/opt/buttontask/config` и `icons` **не затираются**.
### Безопасность
Смена пароля веба и панели.
---
## Проверка с ПК (необязательно)
Для отладки HTTP без реального сервиса на другой машине в сети:
```bash
python3 tools/button-test-server.py --host 0.0.0.0 --port 8765
```
Примеры URL для кнопки:
| URL | Поведение |
|-----|-----------|
| `http://<IP-ПК>:8765/ok` | Простой HTTP 200 |
| `http://<IP-ПК>:8765/state?phase=error` | JSON `success: error` |
| `http://<IP-ПК>:8765/poll?pending=3` | 3× pending, затем ok |
| `http://<IP-ПК>:8765/nested?status=ready` | Вложенное поле `data.status` |
На Windows откройте порт 8765 в брандмауэре. Перед тестом poll: `http://<IP-ПК>:8765/reset`.
---
## Типичные проблемы
| Симптом | Причина | Что сделать |
|---------|---------|-------------|
| Всегда зелёный, один запрос | Правила с результатом **Принято** для всех значений | Исправить колонку «результат» |
| Сообщение = URL кнопки | URL попал в «Текст успеха» | Вписать нормальный текст |
| Polling не идёт | Для `pending` стоит результат **Принято** | Поставить **Ожидание** |
| `phase=error`, а правило не срабатывает | В JSON поле `success`, не `phase` | Правило по полю `success` |
| Веб не открывается | Неверный IP / порт / firewall | Проверить `192.168.1.60:8080`, вкладка «Сеть» |
| Настройки не видны на панели | Не сохранили в вебе | Нажать **Сохранить** в диалоге кнопки |
### Просмотр конфига на устройстве (SSH)
```bash
sudo cat /opt/buttontask/config/config.json
```
Проверка последнего нажатия:
```bash
tail -1 /opt/buttontask/run/button-log.jsonl | python3 -m json.tool
```
Ожидаемые поля при работающей расширенной проверке: `"outcome": "ok"` / `"error"` / `"pending"`, при простом HTTP — `"outcome": "http"`.
---
## Минимальный чеклист для новой кнопки
1. [ ] URL сервиса доступен с панели (`ping`, HTTP).
2. [ ] Тексты успеха/ошибки/ожидания заполнены осмысленно.
3. [ ] Если нужен JSON — включена расширенная проверка, добавлены правила с **правильным результатом**.
4. [ ] Для долгих операций — правило **Ожидание** + блок polling.
5. [ ] Проверка на панели + запись в журнале на вкладке «Система».
+20
View File
@@ -10,6 +10,7 @@ Dialog {
standardButtons: Dialog.Ok | Dialog.Cancel
property string editId: ""
property bool responseCheckWasEnabled: false
function openForCreate() {
editId = ""
@@ -24,6 +25,8 @@ Dialog {
typeCombo.currentIndex = 0
holdSwitch.checked = true
holdSlider.value = 800
responseCheckSwitch.checked = false
responseCheckWasEnabled = false
open()
}
@@ -45,6 +48,8 @@ Dialog {
typeCombo.currentIndex = (o.actionType === "http_post") ? 1 : 0
holdSwitch.checked = (o.triggerMode !== "click")
holdSlider.value = (o.holdMs && o.holdMs > 0) ? o.holdMs : 800
responseCheckSwitch.checked = !!o.responseCheckEnabled
responseCheckWasEnabled = responseCheckSwitch.checked
open()
}
@@ -141,6 +146,19 @@ Dialog {
value: 800
}
}
RowLayout {
Layout.fillWidth: true
Text { text: "Расширенная проверка:"; color: settingsContainer.darkMode ? "white" : "black" }
Switch { id: responseCheckSwitch }
Text {
Layout.fillWidth: true
text: "Правила JSON — в веб-конфигураторе"
color: settingsContainer.darkMode ? "#cccccc" : "#444444"
font.pixelSize: 11
wrapMode: Text.WordWrap
}
}
}
onAccepted: {
@@ -156,6 +174,8 @@ Dialog {
typeCombo.currentText, urlF.text,
successF.text, errorF.text, pendingF.text,
colorF.text, mode, hold)
if (responseCheckSwitch.checked !== responseCheckWasEnabled)
btnController.setResponseCheckEnabled(editId, responseCheckSwitch.checked)
}
}
}
+285 -82
View File
@@ -1,6 +1,7 @@
#include "buttoncontroller.h"
#include "configmanager.h"
#include "buttonsmodel.h"
#include "responsecheck.h"
#include <QNetworkRequest>
#include <QNetworkReply>
@@ -15,7 +16,6 @@
#include <QDebug>
#include <QDateTime>
#include <QFile>
#include <QJsonDocument>
namespace {
@@ -52,7 +52,9 @@ void appendButtonLog(const QJsonObject &entry)
void logButtonResult(const QString &label, const QString &method,
const QString &url, bool ok, int httpStatus,
qint64 elapsedMs, const QString &message)
qint64 elapsedMs, const QString &message,
int attempt = 0, const QString &outcome = QString(),
const QString &matchedRule = QString())
{
QJsonObject log;
log.insert(QStringLiteral("ts"),
@@ -65,9 +67,32 @@ void logButtonResult(const QString &label, const QString &method,
log.insert(QStringLiteral("http"), httpStatus);
log.insert(QStringLiteral("ms"), elapsedMs);
log.insert(QStringLiteral("message"), message);
if (attempt > 0)
log.insert(QStringLiteral("attempt"), attempt);
if (!outcome.isEmpty())
log.insert(QStringLiteral("outcome"), outcome);
if (!matchedRule.isEmpty())
log.insert(QStringLiteral("matchedRule"), matchedRule);
appendButtonLog(log);
}
QString httpMethodForAction(const QJsonObject &action)
{
const QString type = action.value(QStringLiteral("type")).toString(QStringLiteral("http_get"));
return type == QLatin1String("http_post") ? QStringLiteral("POST") : QStringLiteral("GET");
}
QString outcomeName(ResponseOutcome outcome)
{
switch (outcome) {
case ResponseOutcome::Ok: return QStringLiteral("ok");
case ResponseOutcome::Pending: return QStringLiteral("pending");
case ResponseOutcome::Error: return QStringLiteral("error");
case ResponseOutcome::UseHttpFallback: return QStringLiteral("http");
}
return QString();
}
} // namespace
ButtonController::ButtonController(ConfigManager *config, ButtonsModel *model, QObject *parent)
@@ -78,6 +103,243 @@ ButtonController::ButtonController(ConfigManager *config, ButtonsModel *model, Q
{
}
ButtonController::~ButtonController()
{
for (auto it = m_active.begin(); it != m_active.end(); ++it) {
if (it->pollTimer) {
it->pollTimer->stop();
it->pollTimer->deleteLater();
it->pollTimer = nullptr;
}
}
m_active.clear();
}
void ButtonController::cancelActiveRequest(const QString &id)
{
auto it = m_active.find(id);
if (it == m_active.end())
return;
++it->generation;
if (it->pollTimer) {
it->pollTimer->stop();
it->pollTimer->deleteLater();
it->pollTimer = nullptr;
}
m_active.erase(it);
}
void ButtonController::finishRequest(const QString &id, int status, bool success,
const QString &message, int httpStatus,
const QString &outcome, const QString &matchedRule)
{
auto it = m_active.find(id);
if (it == m_active.end())
return;
const RequestState state = *it;
const qint64 elapsedMs = qMax<qint64>(
0, QDateTime::currentMSecsSinceEpoch() - state.startedMs);
const QString method = httpMethodForAction(state.action);
const QString url = state.action.value(QStringLiteral("url")).toString();
logButtonResult(state.label, method, url, success, httpStatus, elapsedMs, message,
state.attempt, outcome, matchedRule);
m_model->setStatus(id, status, message);
emit buttonInvoked(id, success, message);
const int fadeMs = state.feedback.value(QStringLiteral("fadeMs")).toInt(5000);
QString idCopy = id;
QPointer<ButtonsModel> modelPtr = m_model;
QTimer::singleShot(fadeMs, this, [modelPtr, idCopy]() {
if (modelPtr)
modelPtr->setStatus(idCopy, 0, QString());
});
cancelActiveRequest(id);
}
void ButtonController::schedulePoll(const QString &id, const QString &message)
{
auto it = m_active.find(id);
if (it == m_active.end())
return;
RequestState &state = it.value();
const QJsonObject responseCheck = state.action.value(QStringLiteral("responseCheck")).toObject();
const QJsonObject poll = responseCheck.value(QStringLiteral("poll")).toObject();
const int intervalMs = qMax(500, poll.value(QStringLiteral("intervalMs")).toInt(3000));
const int maxAttempts = qMax(1, poll.value(QStringLiteral("maxAttempts")).toInt(20));
const int timeoutMs = qMax(intervalMs, poll.value(QStringLiteral("timeoutMs")).toInt(120000));
const qint64 elapsedMs = qMax<qint64>(
0, QDateTime::currentMSecsSinceEpoch() - state.startedMs);
if (state.attempt >= maxAttempts || elapsedMs >= timeoutMs) {
finishRequest(id, 2, false,
QStringLiteral("Таймаут ожидания"),
0, QStringLiteral("error"));
return;
}
m_model->setStatus(id, 3, message);
if (!state.pollTimer) {
state.pollTimer = new QTimer(this);
state.pollTimer->setSingleShot(true);
connect(state.pollTimer, &QTimer::timeout, this, [this, id]() {
auto activeIt = m_active.find(id);
if (activeIt == m_active.end())
return;
QJsonObject btn;
btn.insert(QStringLiteral("label"), activeIt->label);
btn.insert(QStringLiteral("action"), activeIt->action);
btn.insert(QStringLiteral("feedback"), activeIt->feedback);
startRequest(id, btn, true);
});
}
const int generation = state.generation;
state.pollTimer->start(intervalMs);
Q_UNUSED(generation);
}
void ButtonController::startRequest(const QString &id, const QJsonObject &btn, bool isPoll)
{
const QJsonObject action = btn.value(QStringLiteral("action")).toObject();
const QJsonObject feedback = btn.value(QStringLiteral("feedback")).toObject();
const QString type = action.value(QStringLiteral("type")).toString(QStringLiteral("http_get"));
const QString url = action.value(QStringLiteral("url")).toString();
const int timeoutMs = action.value(QStringLiteral("timeoutMs")).toInt(7000);
const QString pendingText = feedback.value(QStringLiteral("pendingText")).toString(QStringLiteral("..."));
const QString errorText = feedback.value(QStringLiteral("errorText")).toString(QStringLiteral("Ошибка"));
const QString label = btn.value(QStringLiteral("label")).toString();
if (url.isEmpty()) {
qWarning() << "startRequest: empty url for" << id;
m_model->setStatus(id, 2, errorText);
logButtonResult(label, httpMethodForAction(action), url, false, 0, 0, errorText);
emit buttonInvoked(id, false, errorText);
cancelActiveRequest(id);
return;
}
if (!isPoll) {
RequestState state;
state.id = id;
state.label = label;
state.action = action;
state.feedback = feedback;
state.startedMs = QDateTime::currentMSecsSinceEpoch();
state.attempt = 1;
state.generation = 1;
state.statusMessage = pendingText;
m_active.insert(id, state);
m_model->setStatus(id, 3, pendingText);
} else {
auto it = m_active.find(id);
if (it == m_active.end())
return;
++it->attempt;
m_model->setStatus(id, 3,
it->statusMessage.isEmpty() ? pendingText : it->statusMessage);
}
const int generation = m_active.value(id).generation;
QNetworkRequest req{QUrl(url)};
req.setAttribute(QNetworkRequest::RedirectPolicyAttribute,
QNetworkRequest::NoLessSafeRedirectPolicy);
const QJsonObject headers = action.value(QStringLiteral("headers")).toObject();
for (auto hit = headers.begin(); hit != headers.end(); ++hit)
req.setRawHeader(hit.key().toUtf8(), hit.value().toString().toUtf8());
QNetworkReply *reply = nullptr;
if (type == QLatin1String("http_post")) {
const QString body = action.value(QStringLiteral("body")).toString();
if (!req.header(QNetworkRequest::ContentTypeHeader).isValid())
req.setHeader(QNetworkRequest::ContentTypeHeader, "text/plain");
reply = m_nam->post(req, body.toUtf8());
} else {
reply = m_nam->get(req);
}
QTimer *timeout = new QTimer(reply);
timeout->setSingleShot(true);
timeout->start(timeoutMs > 0 ? timeoutMs : 7000);
connect(timeout, &QTimer::timeout, reply, [reply]() {
if (reply && reply->isRunning())
reply->abort();
});
connect(reply, &QNetworkReply::finished, this, [this, reply, id, generation]() {
handleReplyFinished(reply, id, generation);
reply->deleteLater();
});
}
void ButtonController::handleReplyFinished(QNetworkReply *reply, const QString &id, int generation)
{
auto it = m_active.find(id);
if (it == m_active.end() || it->generation != generation)
return;
const RequestState state = *it;
const QJsonObject action = state.action;
const QJsonObject feedback = state.feedback;
const bool acceptAnyResponse = action.value(QStringLiteral("acceptAnyResponse")).toBool(false);
const QString successText = feedback.value(QStringLiteral("successText"))
.toString(state.label);
const QString errorText = feedback.value(QStringLiteral("errorText"))
.toString(QStringLiteral("Ошибка"));
const QJsonObject responseCheck = action.value(QStringLiteral("responseCheck")).toObject();
const int httpStatus =
reply->attribute(QNetworkRequest::HttpStatusCodeAttribute).toInt();
const QByteArray body = reply->readAll();
if (httpStatus <= 0 && reply->error() != QNetworkReply::NoError) {
finishRequest(id, 2, false, errorText, httpStatus, QStringLiteral("error"));
return;
}
ResponseEvaluation evaluation = evaluateResponse(body, httpStatus, responseCheck, feedback);
if (evaluation.outcome == ResponseOutcome::UseHttpFallback) {
bool ok = false;
if (httpStatus > 0)
ok = acceptAnyResponse ? true : (httpStatus < 400);
else
ok = (reply->error() == QNetworkReply::NoError);
const QString message = ok ? successText : errorText;
finishRequest(id, ok ? 1 : 2, ok, message, httpStatus, QStringLiteral("http"));
return;
}
const QString outcome = outcomeName(evaluation.outcome);
if (evaluation.outcome == ResponseOutcome::Ok) {
finishRequest(id, 1, true, evaluation.message, httpStatus, outcome, evaluation.matchedRule);
return;
}
if (evaluation.outcome == ResponseOutcome::Error) {
finishRequest(id, 2, false, evaluation.message, httpStatus, outcome, evaluation.matchedRule);
return;
}
const QJsonObject poll = responseCheck.value(QStringLiteral("poll")).toObject();
if (poll.isEmpty() || poll.value(QStringLiteral("intervalMs")).toInt(0) <= 0) {
finishRequest(id, 3, false, evaluation.message, httpStatus, outcome, evaluation.matchedRule);
return;
}
it->statusMessage = evaluation.message;
const qint64 elapsedMs = qMax<qint64>(
0, QDateTime::currentMSecsSinceEpoch() - state.startedMs);
logButtonResult(state.label, httpMethodForAction(state.action),
state.action.value(QStringLiteral("url")).toString(),
false, httpStatus, elapsedMs, evaluation.message,
state.attempt, outcome, evaluation.matchedRule);
m_model->setStatus(id, 3, evaluation.message);
schedulePoll(id, evaluation.message);
}
void ButtonController::invokeButton(const QString &id)
{
QJsonArray arr = m_config->buttons();
@@ -85,7 +347,7 @@ void ButtonController::invokeButton(const QString &id)
bool found = false;
for (const auto &v : arr) {
QJsonObject b = v.toObject();
if (b.value("id").toString() == id) {
if (b.value(QStringLiteral("id")).toString() == id) {
btn = b;
found = true;
break;
@@ -101,85 +363,8 @@ void ButtonController::invokeButton(const QString &id)
return;
}
QJsonObject action = btn.value("action").toObject();
QJsonObject feedback = btn.value("feedback").toObject();
QString type = action.value("type").toString("http_get");
QString url = action.value("url").toString();
int fadeMs = feedback.value("fadeMs").toInt(5000);
int timeoutMs = action.value("timeoutMs").toInt(7000);
// When true, ANY received HTTP response (even 4xx/5xx) counts as success;
// only transport errors (timeout / refused / DNS) fail. Default: success on
// 1xx/2xx/3xx, failure on 4xx/5xx and transport errors.
bool acceptAnyResponse = action.value("acceptAnyResponse").toBool(false);
QString successText = feedback.value("successText").toString(btn.value("label").toString());
QString errorText = feedback.value("errorText").toString(QStringLiteral("Ошибка"));
QString pendingText = feedback.value("pendingText").toString(QStringLiteral("..."));
QString label = btn.value("label").toString();
QString httpMethod = (type == QLatin1String("http_post")) ? QStringLiteral("POST")
: QStringLiteral("GET");
const qint64 startedMs = QDateTime::currentMSecsSinceEpoch();
if (url.isEmpty()) {
qWarning() << "invokeButton: empty url for" << id;
m_model->setStatus(id, 2, errorText);
logButtonResult(label, httpMethod, url, false, 0, 0, errorText);
emit buttonInvoked(id, false, errorText);
return;
}
m_model->setStatus(id, 3, pendingText);
QNetworkRequest req((QUrl(url)));
// Follow redirects so a 3xx that lands on a final 200 is treated correctly.
req.setAttribute(QNetworkRequest::RedirectPolicyAttribute,
QNetworkRequest::NoLessSafeRedirectPolicy);
QJsonObject headers = action.value("headers").toObject();
for (auto it = headers.begin(); it != headers.end(); ++it)
req.setRawHeader(it.key().toUtf8(), it.value().toString().toUtf8());
QNetworkReply *reply = nullptr;
if (type == "http_post") {
QString body = action.value("body").toString();
if (!req.header(QNetworkRequest::ContentTypeHeader).isValid())
req.setHeader(QNetworkRequest::ContentTypeHeader, "text/plain");
reply = m_nam->post(req, body.toUtf8());
} else {
reply = m_nam->get(req);
}
QTimer *timeout = new QTimer(reply);
timeout->setSingleShot(true);
timeout->start(timeoutMs > 0 ? timeoutMs : 7000);
connect(timeout, &QTimer::timeout, reply, [reply]() {
if (reply && reply->isRunning()) reply->abort();
});
connect(reply, &QNetworkReply::finished, this,
[this, reply, id, label, httpMethod, url, successText, errorText,
fadeMs, acceptAnyResponse, startedMs]() {
const int httpStatus =
reply->attribute(QNetworkRequest::HttpStatusCodeAttribute).toInt();
bool ok;
if (httpStatus > 0) {
// We got an HTTP response: server is reachable and replied.
ok = acceptAnyResponse ? true : (httpStatus < 400);
} else {
// No HTTP status -> transport-level outcome (timeout/refused/DNS).
ok = (reply->error() == QNetworkReply::NoError);
}
QString msg = ok ? successText : errorText;
const qint64 elapsedMs = qMax<qint64>(
0, QDateTime::currentMSecsSinceEpoch() - startedMs);
logButtonResult(label, httpMethod, url, ok, httpStatus, elapsedMs, msg);
m_model->setStatus(id, ok ? 1 : 2, msg);
emit buttonInvoked(id, ok, msg);
QString idCopy = id;
QPointer<ButtonsModel> modelPtr = m_model;
QTimer::singleShot(fadeMs, this, [modelPtr, idCopy]() {
if (modelPtr) modelPtr->setStatus(idCopy, 0, QString());
});
reply->deleteLater();
});
cancelActiveRequest(id);
startRequest(id, btn, false);
}
bool ButtonController::checkPassword(const QString &pwd) const
@@ -276,8 +461,26 @@ void ButtonController::updateButton(const QString &id,
}
}
void ButtonController::setResponseCheckEnabled(const QString &id, bool enabled)
{
QJsonArray arr = m_config->buttons();
for (const auto &v : arr) {
QJsonObject b = v.toObject();
if (b.value(QStringLiteral("id")).toString() != id)
continue;
QJsonObject action = b.value(QStringLiteral("action")).toObject();
QJsonObject responseCheck = action.value(QStringLiteral("responseCheck")).toObject();
responseCheck.insert(QStringLiteral("enabled"), enabled);
action.insert(QStringLiteral("responseCheck"), responseCheck);
b.insert(QStringLiteral("action"), action);
m_config->updateButton(id, b);
return;
}
}
void ButtonController::removeButton(const QString &id)
{
cancelActiveRequest(id);
m_config->removeButton(id);
}
+26
View File
@@ -4,10 +4,13 @@
#include <QObject>
#include <QString>
#include <QJsonObject>
#include <QHash>
#include <QNetworkAccessManager>
class ConfigManager;
class ButtonsModel;
class QNetworkReply;
class QTimer;
class ButtonController : public QObject
{
@@ -15,6 +18,7 @@ class ButtonController : public QObject
public:
explicit ButtonController(ConfigManager *config, ButtonsModel *model, QObject *parent = nullptr);
~ButtonController() override;
Q_INVOKABLE void invokeButton(const QString &id);
Q_INVOKABLE bool checkPassword(const QString &pwd) const;
@@ -41,6 +45,7 @@ public:
const QString &color = QString(),
const QString &triggerMode = QString(),
int holdMs = 0);
Q_INVOKABLE void setResponseCheckEnabled(const QString &id, bool enabled);
Q_INVOKABLE void removeButton(const QString &id);
Q_INVOKABLE void moveButton(int from, int to);
@@ -69,9 +74,30 @@ signals:
void buttonInvoked(QString id, bool success, QString message);
private:
struct RequestState {
QString id;
QString label;
QJsonObject action;
QJsonObject feedback;
QString statusMessage;
qint64 startedMs = 0;
int attempt = 0;
int generation = 0;
QTimer *pollTimer = nullptr;
};
void cancelActiveRequest(const QString &id);
void startRequest(const QString &id, const QJsonObject &btn, bool isPoll);
void schedulePoll(const QString &id, const QString &message);
void finishRequest(const QString &id, int status, bool success, const QString &message,
int httpStatus, const QString &outcome,
const QString &matchedRule = QString());
void handleReplyFinished(QNetworkReply *reply, const QString &id, int generation);
ConfigManager *m_config;
ButtonsModel *m_model;
QNetworkAccessManager *m_nam;
QHash<QString, RequestState> m_active;
};
#endif // BUTTONCONTROLLER_H
+4
View File
@@ -65,6 +65,9 @@ QVariant ButtonsModel::data(const QModelIndex &index, int role) const
case SuccessTextRole: return feedback.value("successText").toString();
case ErrorTextRole: return feedback.value("errorText").toString();
case PendingTextRole: return feedback.value("pendingText").toString(QStringLiteral("..."));
case ResponseCheckEnabledRole:
return action.value(QStringLiteral("responseCheck")).toObject()
.value(QStringLiteral("enabled")).toBool(false);
case FadeMsRole: return feedback.value("fadeMs").toInt(5000);
case StatusRole: return rt.status;
case StatusTextRole: return rt.text;
@@ -88,6 +91,7 @@ QHash<int, QByteArray> ButtonsModel::roleNames() const
{SuccessTextRole, "successText"},
{ErrorTextRole, "errorText"},
{PendingTextRole, "pendingText"},
{ResponseCheckEnabledRole, "responseCheckEnabled"},
{FadeMsRole, "fadeMs"},
{StatusRole, "status"},
{StatusTextRole, "statusText"},
+1
View File
@@ -24,6 +24,7 @@ public:
SuccessTextRole,
ErrorTextRole,
PendingTextRole,
ResponseCheckEnabledRole,
FadeMsRole,
StatusRole, // 0=idle, 1=ok, 2=error, 3=in-progress
StatusTextRole,
+151
View File
@@ -0,0 +1,151 @@
#include "responsecheck.h"
#include <QJsonArray>
#include <QJsonDocument>
#include <QJsonValue>
namespace {
QString jsonValueToString(const QJsonValue &value)
{
if (value.isString())
return value.toString();
if (value.isDouble())
return QString::number(value.toDouble());
if (value.isBool())
return value.toBool() ? QStringLiteral("true") : QStringLiteral("false");
if (value.isNull() || value.isUndefined())
return QString();
if (value.isArray())
return QString::fromUtf8(QJsonDocument(value.toArray()).toJson(QJsonDocument::Compact));
if (value.isObject())
return QString::fromUtf8(QJsonDocument(value.toObject()).toJson(QJsonDocument::Compact));
return value.toVariant().toString();
}
QJsonValue valueAtPath(const QJsonValue &root, const QString &dotPath)
{
if (dotPath.isEmpty())
return QJsonValue();
const QStringList parts = dotPath.split(QLatin1Char('.'), QString::SkipEmptyParts);
QJsonValue current = root;
for (const QString &part : parts) {
if (!current.isObject())
return QJsonValue();
current = current.toObject().value(part);
}
return current;
}
QString defaultMessage(ResponseOutcome outcome, const QJsonObject &feedback)
{
switch (outcome) {
case ResponseOutcome::Ok:
return feedback.value(QStringLiteral("successText")).toString();
case ResponseOutcome::Pending:
return feedback.value(QStringLiteral("pendingText")).toString(QStringLiteral("..."));
case ResponseOutcome::Error:
default:
return feedback.value(QStringLiteral("errorText")).toString(QStringLiteral("Ошибка"));
}
}
ResponseOutcome outcomeFromString(const QString &value)
{
const QString v = value.trimmed().toLower();
if (v == QLatin1String("ok"))
return ResponseOutcome::Ok;
if (v == QLatin1String("pending"))
return ResponseOutcome::Pending;
return ResponseOutcome::Error;
}
bool conditionMatches(const QJsonObject &condition, const QJsonValue &root)
{
const QString field = condition.value(QStringLiteral("field")).toString();
if (field.isEmpty())
return false;
const QString actual = jsonValueToString(valueAtPath(root, field));
if (condition.contains(QStringLiteral("equals")))
return actual == condition.value(QStringLiteral("equals")).toString();
if (condition.contains(QStringLiteral("contains")))
return actual.contains(condition.value(QStringLiteral("contains")).toString());
return false;
}
bool ruleMatches(const QJsonObject &rule, const QJsonValue &root)
{
if (rule.contains(QStringLiteral("match"))) {
const QJsonArray conditions = rule.value(QStringLiteral("match")).toArray();
if (conditions.isEmpty())
return false;
for (const QJsonValue &value : conditions) {
if (!conditionMatches(value.toObject(), root))
return false;
}
return true;
}
QJsonObject condition;
condition.insert(QStringLiteral("field"), rule.value(QStringLiteral("field")));
if (rule.contains(QStringLiteral("equals")))
condition.insert(QStringLiteral("equals"), rule.value(QStringLiteral("equals")));
if (rule.contains(QStringLiteral("contains")))
condition.insert(QStringLiteral("contains"), rule.value(QStringLiteral("contains")));
return conditionMatches(condition, root);
}
ResponseEvaluation makeEvaluation(ResponseOutcome outcome,
const QJsonObject &feedback,
const QString &message = QString(),
const QString &matchedRule = QString())
{
ResponseEvaluation result;
result.outcome = outcome;
result.matchedRule = matchedRule;
result.message = message.isEmpty() ? defaultMessage(outcome, feedback) : message;
return result;
}
} // namespace
ResponseEvaluation evaluateResponse(const QByteArray &body,
int httpStatus,
const QJsonObject &responseCheck,
const QJsonObject &feedbackDefaults)
{
if (!responseCheck.value(QStringLiteral("enabled")).toBool(false))
return ResponseEvaluation();
if (responseCheck.value(QStringLiteral("requireHttpSuccess")).toBool(true)
&& httpStatus >= 400) {
return makeEvaluation(ResponseOutcome::Error, feedbackDefaults);
}
QJsonParseError parseError;
const QJsonDocument doc = QJsonDocument::fromJson(body, &parseError);
if (parseError.error != QJsonParseError::NoError || !doc.isObject()) {
return makeEvaluation(ResponseOutcome::Error, feedbackDefaults,
feedbackDefaults.value(QStringLiteral("errorText"))
.toString(QStringLiteral("Ошибка")));
}
const QJsonValue root = doc.object();
const QJsonArray rules = responseCheck.value(QStringLiteral("rules")).toArray();
for (const QJsonValue &value : rules) {
const QJsonObject rule = value.toObject();
if (!ruleMatches(rule, root))
continue;
const ResponseOutcome outcome = outcomeFromString(rule.value(QStringLiteral("result")).toString());
const QString matchedRule = rule.value(QStringLiteral("field")).toString();
const QString message = rule.value(QStringLiteral("message")).toString();
return makeEvaluation(outcome, feedbackDefaults, message, matchedRule);
}
const ResponseOutcome fallback = outcomeFromString(
responseCheck.value(QStringLiteral("defaultResult")).toString(QStringLiteral("error")));
return makeEvaluation(fallback, feedbackDefaults);
}
+25
View File
@@ -0,0 +1,25 @@
#ifndef RESPONSECHECK_H
#define RESPONSECHECK_H
#include <QJsonObject>
#include <QString>
enum class ResponseOutcome {
UseHttpFallback,
Ok,
Error,
Pending
};
struct ResponseEvaluation {
ResponseOutcome outcome = ResponseOutcome::UseHttpFallback;
QString message;
QString matchedRule;
};
ResponseEvaluation evaluateResponse(const QByteArray &body,
int httpStatus,
const QJsonObject &responseCheck,
const QJsonObject &feedbackDefaults);
#endif // RESPONSECHECK_H
+177 -42
View File
@@ -5,18 +5,23 @@
python tools/button-test-server.py
python tools/button-test-server.py --host 0.0.0.0 --port 8765
Примеры URL для кнопок (подставьте IP вашего ПК):
http://192.168.1.10:8765/ok
http://192.168.1.10:8765/fail
http://192.168.1.10:8765/btn?bid=01
http://192.168.1.10:8765/echo (тип: http_post, body: ping)
http://192.168.1.10:8765/slow?sec=8 (таймаут кнопки по умолчанию 7 с)
Базовые URL (подставьте IP ПК):
http://<ip>:8765/ok
http://<ip>:8765/fail
Расширенная проверка ответа (responseCheck):
http://<ip>:8765/state?phase=ok
http://<ip>:8765/poll?pending=3
http://<ip>:8765/cleaning?result=pending
http://<ip>:8765/nested?status=ready
http://<ip>:8765/reset — сброс счётчиков poll
"""
from __future__ import annotations
import argparse
import json
import sys
import threading
import time
from datetime import datetime
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
@@ -27,8 +32,37 @@ def _now() -> str:
return datetime.now().strftime("%H:%M:%S")
_poll_lock = threading.Lock()
_poll_counts: dict[str, int] = {}
def _poll_key(handler: BaseHTTPRequestHandler, qs: dict) -> str:
custom = (qs.get("key") or [""])[0].strip()
if custom:
return custom
return handler.client_address[0]
def _next_poll_success(key: str, pending_n: int) -> tuple[str, int]:
with _poll_lock:
hit = _poll_counts.get(key, 0) + 1
_poll_counts[key] = hit
if hit <= pending_n:
return "pending", hit
_poll_counts[key] = 0
return "ok", hit
def _reset_poll(key: str | None = None) -> None:
with _poll_lock:
if key is None:
_poll_counts.clear()
else:
_poll_counts.pop(key, None)
class ButtonTestHandler(BaseHTTPRequestHandler):
server_version = "ButtonTestHTTP/1.0"
server_version = "ButtonTestHTTP/1.1"
def log_message(self, fmt: str, *args) -> None:
print(f"[{_now()}] {self.address_string()} {fmt % args}", flush=True)
@@ -38,81 +72,182 @@ class ButtonTestHandler(BaseHTTPRequestHandler):
self.send_response(code)
self.send_header("Content-Type", content_type)
self.send_header("Content-Length", str(len(data)))
self.send_header("Access-Control-Allow-Origin", "*")
self.end_headers()
self.wfile.write(data)
def _send_json(self, code: int, payload: dict) -> None:
self._send(code, json.dumps(payload, ensure_ascii=False), "application/json; charset=utf-8")
def _read_body(self) -> bytes:
length = int(self.headers.get("Content-Length", 0))
return self.rfile.read(length) if length else b""
def do_GET(self) -> None:
path = urlparse(self.path).path
qs = parse_qs(urlparse(self.path).query)
def _qs(self) -> dict:
return parse_qs(urlparse(self.path).query)
def _handle(self, path: str, qs: dict, method: str, body_text: str = "") -> bool:
if path in ("", "/"):
self._send(200, HELP_TEXT)
return
return True
if path == "/ok":
self._send(200, "OK\n")
return
return True
if path == "/fail":
self._send(500, "server error\n")
return
return True
if path == "/btn":
bid = (qs.get("bid") or ["?"])[0]
payload = {"status": "ok", "bid": bid, "ts": time.time()}
self._send(200, json.dumps(payload, ensure_ascii=False), "application/json")
return
self._send_json(200, {"status": "ok", "bid": bid, "ts": time.time()})
return True
if path == "/slow":
sec = float((qs.get("sec") or ["3"])[0])
sec = max(0.0, min(sec, 60.0))
time.sleep(sec)
self._send(200, f"slept {sec:g}s\n")
return
return True
if path == "/redirect":
self.send_response(302)
self.send_header("Location", "/ok")
self.end_headers()
return
return True
self._send(404, f"not found: {path}\n")
if path == "/reset":
key = (qs.get("key") or [""])[0].strip() or None
_reset_poll(key)
msg = f"poll reset: {key or 'all'}"
self._send_json(200, {"success": "ok", "message": msg})
print(f"[{_now()}] {msg}", flush=True)
return True
def do_POST(self) -> None:
path = urlparse(self.path).path
raw = self._read_body()
text = raw.decode("utf-8", errors="replace")
if path == "/state":
phase = (qs.get("phase") or qs.get("success") or ["ok"])[0]
code = int((qs.get("code") or ["200"])[0])
self._send_json(code, {"success": phase, "ts": time.time(), "method": method})
return True
if path == "/poll":
pending_n = int((qs.get("pending") or ["3"])[0])
pending_n = max(0, min(pending_n, 50))
key = _poll_key(self, qs)
success, hit = _next_poll_success(key, pending_n)
self._send_json(200, {
"success": success,
"hit": hit,
"pending": pending_n,
"key": key,
"ts": time.time(),
})
return True
if path == "/cleaning":
result = (qs.get("result") or ["ok"])[0]
self._send_json(200, {
"success": result,
"service": "cleaning",
"message": {
"ok": "Вызов принят",
"pending": "Ожидание клининга",
"error": "Сервис недоступен",
}.get(result, result),
"ts": time.time(),
})
return True
if path == "/nested":
status = (qs.get("status") or ["ready"])[0]
self._send_json(200, {"data": {"status": status}, "ts": time.time()})
return True
if path == "/json":
# Произвольные поля: ?success=ok&code=200 или ?foo=bar&code=201
code = int((qs.get("code") or ["200"])[0])
payload = {k: v[0] for k, v in qs.items() if k != "code"}
if not payload:
payload = {"success": "ok"}
payload["ts"] = time.time()
self._send_json(code, payload)
return True
if path == "/badjson":
self._send(200, "{not-json", "application/json; charset=utf-8")
return True
if path == "/httpfail":
# HTTP 500, но тело с success=ok — для requireHttpSuccess
self._send_json(500, {"success": "ok", "note": "http 500"})
return True
if path == "/echo":
ct = self.headers.get("Content-Type", "text/plain")
out = (
f"method=POST\n"
f"content-type={ct}\n"
f"body={text}\n"
)
self._send(200, out)
return
if "json" in ct.lower() and body_text.strip():
try:
parsed = json.loads(body_text)
except json.JSONDecodeError:
parsed = {"raw": body_text}
self._send_json(200, {
"success": "ok",
"echo": parsed,
"method": method,
})
else:
out = f"method={method}\ncontent-type={ct}\nbody={body_text}\n"
self._send(200, out)
return True
if path == "/fail":
self._send(500, "post failed\n")
return
return False
self._send(404, f"not found: {path}\n")
def do_GET(self) -> None:
path = urlparse(self.path).path
if not self._handle(path, self._qs(), "GET"):
self._send(404, f"not found: {path}\n")
def do_POST(self) -> None:
path = urlparse(self.path).path
body_text = self._read_body().decode("utf-8", errors="replace")
if not self._handle(path, self._qs(), "POST", body_text):
self._send(404, f"not found: {path}\n")
HELP_TEXT = """ButtonTask test server
HELP_TEXT = """ButtonTask test server — сценарии проверки
GET /ok -> 200 (зелёная обводка)
GET /fail -> 500 (красная обводка)
GET /btn?bid=01 -> 200 JSON (как в примере config.json)
GET /slow?sec=3 -> задержка, проверка таймаута
GET /redirect -> 302 -> /ok
POST /echo -> эхо тела запроса
POST /fail -> 500
=== Без responseCheck (только HTTP) ===
GET /ok -> 200
GET /fail -> 500
GET /slow?sec=3 -> задержка
GET /redirect -> 302 -> /ok
GET /httpfail -> 500 + JSON {"success":"ok"}
=== responseCheck: поле success ===
GET /state?phase=ok -> {"success":"ok"}
GET /state?phase=error -> {"success":"error"}
GET /state?phase=pending -> {"success":"pending"}
GET /state?phase=ok&code=500 -> HTTP 500 + JSON
=== Polling (pending -> ok) ===
GET /poll?pending=3 -> 3x pending, затем ok
GET /poll?pending=3&key=btn1 -> отдельный счётчик на кнопку
GET /reset -> сброс всех счётчиков
GET /reset?key=btn1 -> сброс одного
=== Прочее ===
GET /cleaning?result=ok|pending|error
GET /nested?status=ready -> {"data":{"status":"ready"}}
GET /json?success=ok&foo=bar
GET /badjson -> битый JSON при 200
POST /echo -> эхо (JSON in -> JSON out)
POST /state?phase=ok
Пример responseCheck для /poll?pending=3:
rules: success equals ok -> ok
success equals pending -> pending
success equals error -> error
poll: intervalMs=2000, maxAttempts=10
Все запросы пишутся в консоль.
"""
+87 -8
View File
@@ -12,6 +12,90 @@ from config_store import (ALLOWED_ICON_EXT, icons_dir, list_icons, load_config,
api_bp = Blueprint("api", __name__)
_RESPONSE_RESULTS = {"ok", "error", "pending"}
def _validate_response_check(data):
if data is None:
return None
if not isinstance(data, dict):
abort(400, "responseCheck must be an object")
enabled = bool(data.get("enabled", False))
if not enabled:
return {"enabled": False}
rules = data.get("rules", [])
if not isinstance(rules, list):
abort(400, "responseCheck.rules must be an array")
normalized_rules = []
for rule in rules:
if not isinstance(rule, dict):
abort(400, "each responseCheck rule must be an object")
field = (rule.get("field") or "").strip()
if not field:
abort(400, "responseCheck rule field is required")
result = (rule.get("result") or "error").strip().lower()
if result not in _RESPONSE_RESULTS:
abort(400, "responseCheck rule result must be ok, error or pending")
if not rule.get("equals") and rule.get("contains") in (None, ""):
abort(400, "responseCheck rule needs equals or contains")
item = {"field": field, "result": result}
if rule.get("equals") not in (None, ""):
item["equals"] = str(rule.get("equals"))
if rule.get("contains") not in (None, ""):
item["contains"] = str(rule.get("contains"))
message = (rule.get("message") or "").strip()
if message:
item["message"] = message
normalized_rules.append(item)
default_result = (data.get("defaultResult") or "error").strip().lower()
if default_result not in _RESPONSE_RESULTS:
abort(400, "defaultResult must be ok, error or pending")
poll_in = data.get("poll") or {}
if poll_in and not isinstance(poll_in, dict):
abort(400, "responseCheck.poll must be an object")
interval_ms = int(poll_in.get("intervalMs", 3000))
max_attempts = int(poll_in.get("maxAttempts", 20))
timeout_ms = int(poll_in.get("timeoutMs", 120000))
if interval_ms < 500 or interval_ms > 60000:
abort(400, "poll.intervalMs must be 500..60000")
if max_attempts < 1 or max_attempts > 100:
abort(400, "poll.maxAttempts must be 1..100")
if timeout_ms < interval_ms or timeout_ms > 600000:
abort(400, "poll.timeoutMs must be >= intervalMs and <= 600000")
return {
"enabled": True,
"requireHttpSuccess": bool(data.get("requireHttpSuccess", True)),
"rules": normalized_rules,
"defaultResult": default_result,
"poll": {
"intervalMs": interval_ms,
"maxAttempts": max_attempts,
"timeoutMs": timeout_ms,
},
}
def _apply_button_action(action, data):
action["type"] = data.get("actionType", action.get("type", "http_get"))
action["url"] = data.get("url", action.get("url", ""))
if "headers" in data:
action["headers"] = data["headers"]
if "body" in data:
action["body"] = data["body"]
if "responseCheck" in data:
rc = _validate_response_check(data.get("responseCheck"))
if rc is None:
action.pop("responseCheck", None)
else:
action["responseCheck"] = rc
return action
@api_bp.route("/icons/<path:fname>")
@login_required
@@ -46,12 +130,12 @@ def api_add_button():
"id": data.get("id") or uuid.uuid4().hex,
"label": data.get("label", "Кнопка"),
"iconPath": data.get("iconPath", ""),
"action": {
"action": _apply_button_action({
"type": data.get("actionType", "http_get"),
"url": data.get("url", ""),
"headers": data.get("headers", {}),
"body": data.get("body", ""),
},
}, data),
"feedback": {
"successText": data.get("successText", ""),
"errorText": data.get("errorText", "Ошибка"),
@@ -79,12 +163,7 @@ def api_update_button(bid):
b["label"] = data.get("label", b.get("label"))
b["iconPath"] = data.get("iconPath", b.get("iconPath"))
action = b.setdefault("action", {})
action["type"] = data.get("actionType", action.get("type", "http_get"))
action["url"] = data.get("url", action.get("url", ""))
if "headers" in data:
action["headers"] = data["headers"]
if "body" in data:
action["body"] = data["body"]
_apply_button_action(action, data)
feedback = b.setdefault("feedback", {})
feedback["successText"] = data.get("successText", feedback.get("successText", ""))
feedback["errorText"] = data.get("errorText", feedback.get("errorText", "Ошибка"))
+99 -5
View File
@@ -162,6 +162,93 @@ if (list && window.Sortable) {
const dlg = $("#btn-dialog");
const form = $("#btn-form");
function defaultResponseCheck() {
return {
enabled: false,
requireHttpSuccess: true,
defaultResult: "error",
rules: [],
poll: { intervalMs: 3000, maxAttempts: 20, timeoutMs: 120000 },
};
}
function createRuleRow(rule = {}) {
const row = document.createElement("div");
row.className = "response-rule-row";
row.innerHTML = `
<input type="text" class="rc-field" placeholder="поле (success)" value="${rule.field || ""}">
<select class="rc-op">
<option value="equals">равно</option>
<option value="contains">содержит</option>
</select>
<input type="text" class="rc-value" placeholder="значение" value="${rule.equals ?? rule.contains ?? ""}">
<select class="rc-result">
<option value="error">Ошибка</option>
<option value="ok">Принято</option>
<option value="pending">Ожидание</option>
</select>
<input type="text" class="rc-message" placeholder="сообщение (опц.)" value="${rule.message || ""}">
<button type="button" class="ghost rc-del" title="Удалить">×</button>
`;
row.querySelector(".rc-op").value = rule.contains != null && rule.contains !== "" ? "contains" : "equals";
row.querySelector(".rc-result").value = rule.result || "error";
row.querySelector(".rc-del").addEventListener("click", () => row.remove());
return row;
}
function fillResponseCheckForm(rc) {
const cfg = rc || defaultResponseCheck();
$("#b-rc-enabled").checked = !!cfg.enabled;
$("#b-rc-require-http").checked = cfg.requireHttpSuccess !== false;
$("#b-rc-default").value = cfg.defaultResult || "error";
$("#b-rc-poll-interval").value = cfg.poll?.intervalMs ?? 3000;
$("#b-rc-poll-attempts").value = cfg.poll?.maxAttempts ?? 20;
$("#b-rc-poll-timeout").value = cfg.poll?.timeoutMs ?? 120000;
const wrap = $("#b-rc-rules");
wrap.innerHTML = "";
(cfg.rules || []).forEach(rule => wrap.appendChild(createRuleRow(rule)));
syncResponseCheckUi();
}
function readResponseCheckFromForm() {
if (!$("#b-rc-enabled").checked) {
return { enabled: false };
}
const rules = Array.from($$("#b-rc-rules .response-rule-row")).map(row => {
const field = row.querySelector(".rc-field").value.trim();
const op = row.querySelector(".rc-op").value;
const value = row.querySelector(".rc-value").value;
const result = row.querySelector(".rc-result").value;
const message = row.querySelector(".rc-message").value.trim();
const rule = { field, result };
rule[op] = value;
if (message) rule.message = message;
return rule;
}).filter(r => r.field);
return {
enabled: true,
requireHttpSuccess: $("#b-rc-require-http").checked,
defaultResult: $("#b-rc-default").value,
rules,
poll: {
intervalMs: parseInt($("#b-rc-poll-interval").value, 10) || 3000,
maxAttempts: parseInt($("#b-rc-poll-attempts").value, 10) || 20,
timeoutMs: parseInt($("#b-rc-poll-timeout").value, 10) || 120000,
},
};
}
function syncResponseCheckUi() {
const on = $("#b-rc-enabled")?.checked;
const adv = $("#b-rc-advanced");
if (adv) adv.classList.toggle("show", !!on);
}
$("#b-rc-enabled")?.addEventListener("change", syncResponseCheckUi);
$("#b-rc-add-rule")?.addEventListener("click", () => {
$("#b-rc-rules")?.appendChild(createRuleRow());
});
function syncHoldRow() {
const mode = $("#b-trigger-mode").value;
$("#b-hold-row").style.display = mode === "click" ? "none" : "";
@@ -181,6 +268,7 @@ function openDialogFor(btn) {
$("#b-color").value = btn?.color || "#7b007b";
$("#b-trigger-mode").value = btn?.trigger?.mode || "hold";
$("#b-hold").value = btn?.trigger?.holdMs || 800;
fillResponseCheckForm(btn?.action?.responseCheck);
syncHoldRow();
dlg.showModal();
}
@@ -223,6 +311,7 @@ form?.addEventListener("submit", async (e) => {
color: $("#b-color").value,
triggerMode: $("#b-trigger-mode").value,
holdMs: parseInt($("#b-hold").value, 10),
responseCheck: readResponseCheckFromForm(),
};
if (id) await api("PUT", `/api/buttons/${encodeURIComponent(id)}`, payload);
else await api("POST", "/api/buttons", payload);
@@ -325,17 +414,22 @@ function renderButtonLog(entries) {
return;
}
body.innerHTML = entries.map(e => {
const ok = e.ok;
const result = ok
? (e.http ? `OK ${e.http}` : "OK") + (e.message ? `${e.message}` : "")
: (e.http ? `ERR ${e.http}` : "ERR") + (e.message ? `${e.message}` : "");
const outcome = e.outcome || (e.ok ? "ok" : "error");
const ok = outcome === "ok";
const pending = outcome === "pending";
const http = e.http ? ` ${e.http}` : "";
const attempt = e.attempt > 1 ? ` [${e.attempt}]` : "";
const result = (ok ? "OK" : pending ? "WAIT" : "ERR") + http
+ attempt
+ (e.message ? `${e.message}` : "");
const cls = ok ? "log-ok" : pending ? "log-wait" : "log-err";
return `
<tr>
<td>${e.ts || "—"}</td>
<td>${e.label || "—"}</td>
<td>${e.method || "—"}</td>
<td class="log-path" title="${e.url || ""}">${e.url || "—"}</td>
<td class="${ok ? "log-ok" : "log-err"}">${result}</td>
<td class="${cls}">${result}</td>
<td>${e.ms != null ? e.ms : "—"}</td>
</tr>`;
}).join("");
+30 -1
View File
@@ -352,6 +352,7 @@ button.icon-only { padding: 6px 9px; line-height: 1; }
.req-log .log-path { max-width: 280px; overflow: hidden; text-overflow: ellipsis; }
.req-log .log-err { color: #f87171; }
.req-log .log-ok { color: #4ade80; }
.req-log .log-wait { color: #fcd34d; }
.req-log .empty-cell { color: var(--muted); text-align: center; }
/* ---------- Dialog ---------- */
@@ -361,13 +362,41 @@ dialog {
border-radius: var(--radius);
color: var(--text);
padding: 22px;
min-width: 360px;
min-width: min(96vw, 760px);
}
dialog::backdrop { background: rgba(0,0,0,0.6); }
dialog form { display: flex; flex-direction: column; gap: 10px; }
dialog h3 { margin: 0 0 6px; }
dialog menu { display: flex; justify-content: flex-end; gap: 8px; padding: 0; margin: 14px 0 0; }
.response-check-block { margin-top: 6px; }
.response-check-advanced { display: none; margin-top: 10px; gap: 10px; flex-direction: column; }
.response-check-advanced.show { display: flex; }
.response-rules-head {
display: flex;
align-items: center;
justify-content: space-between;
margin-top: 4px;
}
.response-rule-row {
display: grid;
grid-template-columns: 1.2fr 0.8fr 1fr 0.9fr 1.2fr auto;
gap: 6px;
margin-bottom: 6px;
}
.response-rule-row input,
.response-rule-row select {
width: 100%;
min-width: 0;
}
.response-check-poll { margin-top: 4px; }
@media (max-width: 720px) {
.response-rule-row {
grid-template-columns: 1fr 1fr;
}
.response-rule-row .rc-del { grid-column: 2; }
}
/* ---------- Toast ---------- */
.toast {
position: fixed; bottom: 24px; right: 24px;
+39
View File
@@ -380,6 +380,45 @@
<label id="b-hold-row">Длительность удержания: <span id="b-hold-val">800</span> мс
<input type="range" id="b-hold" min="200" max="3000" step="50" value="800">
</label>
<div class="response-check-block" id="b-response-check-block">
<label class="check">
<input type="checkbox" id="b-rc-enabled">
Расширенная проверка ответа (JSON)
</label>
<div id="b-rc-advanced" class="response-check-advanced">
<p class="hint">Пример ответа: <code>{"success":"ok"}</code>. Без этого блока проверяется только HTTP-код. В колонке результата правила выберите <strong>Ошибка</strong> / <strong>Ожидание</strong> / <strong>Принято</strong> — не оставляйте «Принято» для условий ошибки.</p>
<label class="check">
<input type="checkbox" id="b-rc-require-http" checked>
Требовать HTTP &lt; 400
</label>
<label>Если ни одно правило не подошло
<select id="b-rc-default">
<option value="error">Ошибка</option>
<option value="ok">Принято</option>
<option value="pending">Ожидание</option>
</select>
</label>
<div class="response-rules-head">
<strong>Правила</strong>
<button type="button" id="b-rc-add-rule" class="ghost">+ Правило</button>
</div>
<div id="b-rc-rules"></div>
<h4 class="subhead">Polling при «Ожидание»</h4>
<div class="grid-form response-check-poll">
<label>Интервал, мс
<input type="number" id="b-rc-poll-interval" min="500" max="60000" value="3000">
</label>
<label>Макс. попыток
<input type="number" id="b-rc-poll-attempts" min="1" max="100" value="20">
</label>
<label>Общий таймаут, мс
<input type="number" id="b-rc-poll-timeout" min="1000" max="600000" value="120000">
</label>
</div>
</div>
</div>
<menu>
<button value="cancel">Отмена</button>
<button id="b-save" value="ok" type="submit" class="primary">Сохранить</button>
Binary file not shown.