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/buttonsmodel.h
src/buttoncontroller.cpp src/buttoncontroller.cpp
src/buttoncontroller.h src/buttoncontroller.h
src/responsecheck.cpp
src/responsecheck.h
src/settingscontainer.cpp src/settingscontainer.cpp
src/settingscontainer.h src/settingscontainer.h
src/systeminfo.cpp 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 standardButtons: Dialog.Ok | Dialog.Cancel
property string editId: "" property string editId: ""
property bool responseCheckWasEnabled: false
function openForCreate() { function openForCreate() {
editId = "" editId = ""
@@ -24,6 +25,8 @@ Dialog {
typeCombo.currentIndex = 0 typeCombo.currentIndex = 0
holdSwitch.checked = true holdSwitch.checked = true
holdSlider.value = 800 holdSlider.value = 800
responseCheckSwitch.checked = false
responseCheckWasEnabled = false
open() open()
} }
@@ -45,6 +48,8 @@ Dialog {
typeCombo.currentIndex = (o.actionType === "http_post") ? 1 : 0 typeCombo.currentIndex = (o.actionType === "http_post") ? 1 : 0
holdSwitch.checked = (o.triggerMode !== "click") holdSwitch.checked = (o.triggerMode !== "click")
holdSlider.value = (o.holdMs && o.holdMs > 0) ? o.holdMs : 800 holdSlider.value = (o.holdMs && o.holdMs > 0) ? o.holdMs : 800
responseCheckSwitch.checked = !!o.responseCheckEnabled
responseCheckWasEnabled = responseCheckSwitch.checked
open() open()
} }
@@ -141,6 +146,19 @@ Dialog {
value: 800 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: { onAccepted: {
@@ -156,6 +174,8 @@ Dialog {
typeCombo.currentText, urlF.text, typeCombo.currentText, urlF.text,
successF.text, errorF.text, pendingF.text, successF.text, errorF.text, pendingF.text,
colorF.text, mode, hold) 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 "buttoncontroller.h"
#include "configmanager.h" #include "configmanager.h"
#include "buttonsmodel.h" #include "buttonsmodel.h"
#include "responsecheck.h"
#include <QNetworkRequest> #include <QNetworkRequest>
#include <QNetworkReply> #include <QNetworkReply>
@@ -15,7 +16,6 @@
#include <QDebug> #include <QDebug>
#include <QDateTime> #include <QDateTime>
#include <QFile> #include <QFile>
#include <QJsonDocument>
namespace { namespace {
@@ -52,7 +52,9 @@ void appendButtonLog(const QJsonObject &entry)
void logButtonResult(const QString &label, const QString &method, void logButtonResult(const QString &label, const QString &method,
const QString &url, bool ok, int httpStatus, 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; QJsonObject log;
log.insert(QStringLiteral("ts"), log.insert(QStringLiteral("ts"),
@@ -65,9 +67,32 @@ void logButtonResult(const QString &label, const QString &method,
log.insert(QStringLiteral("http"), httpStatus); log.insert(QStringLiteral("http"), httpStatus);
log.insert(QStringLiteral("ms"), elapsedMs); log.insert(QStringLiteral("ms"), elapsedMs);
log.insert(QStringLiteral("message"), message); 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); 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 } // namespace
ButtonController::ButtonController(ConfigManager *config, ButtonsModel *model, QObject *parent) 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) void ButtonController::invokeButton(const QString &id)
{ {
QJsonArray arr = m_config->buttons(); QJsonArray arr = m_config->buttons();
@@ -85,7 +347,7 @@ void ButtonController::invokeButton(const QString &id)
bool found = false; bool found = false;
for (const auto &v : arr) { for (const auto &v : arr) {
QJsonObject b = v.toObject(); QJsonObject b = v.toObject();
if (b.value("id").toString() == id) { if (b.value(QStringLiteral("id")).toString() == id) {
btn = b; btn = b;
found = true; found = true;
break; break;
@@ -101,85 +363,8 @@ void ButtonController::invokeButton(const QString &id)
return; return;
} }
QJsonObject action = btn.value("action").toObject(); cancelActiveRequest(id);
QJsonObject feedback = btn.value("feedback").toObject(); startRequest(id, btn, false);
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();
});
} }
bool ButtonController::checkPassword(const QString &pwd) const 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) void ButtonController::removeButton(const QString &id)
{ {
cancelActiveRequest(id);
m_config->removeButton(id); m_config->removeButton(id);
} }
+26
View File
@@ -4,10 +4,13 @@
#include <QObject> #include <QObject>
#include <QString> #include <QString>
#include <QJsonObject> #include <QJsonObject>
#include <QHash>
#include <QNetworkAccessManager> #include <QNetworkAccessManager>
class ConfigManager; class ConfigManager;
class ButtonsModel; class ButtonsModel;
class QNetworkReply;
class QTimer;
class ButtonController : public QObject class ButtonController : public QObject
{ {
@@ -15,6 +18,7 @@ class ButtonController : public QObject
public: public:
explicit ButtonController(ConfigManager *config, ButtonsModel *model, QObject *parent = nullptr); explicit ButtonController(ConfigManager *config, ButtonsModel *model, QObject *parent = nullptr);
~ButtonController() override;
Q_INVOKABLE void invokeButton(const QString &id); Q_INVOKABLE void invokeButton(const QString &id);
Q_INVOKABLE bool checkPassword(const QString &pwd) const; Q_INVOKABLE bool checkPassword(const QString &pwd) const;
@@ -41,6 +45,7 @@ public:
const QString &color = QString(), const QString &color = QString(),
const QString &triggerMode = QString(), const QString &triggerMode = QString(),
int holdMs = 0); int holdMs = 0);
Q_INVOKABLE void setResponseCheckEnabled(const QString &id, bool enabled);
Q_INVOKABLE void removeButton(const QString &id); Q_INVOKABLE void removeButton(const QString &id);
Q_INVOKABLE void moveButton(int from, int to); Q_INVOKABLE void moveButton(int from, int to);
@@ -69,9 +74,30 @@ signals:
void buttonInvoked(QString id, bool success, QString message); void buttonInvoked(QString id, bool success, QString message);
private: 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; ConfigManager *m_config;
ButtonsModel *m_model; ButtonsModel *m_model;
QNetworkAccessManager *m_nam; QNetworkAccessManager *m_nam;
QHash<QString, RequestState> m_active;
}; };
#endif // BUTTONCONTROLLER_H #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 SuccessTextRole: return feedback.value("successText").toString();
case ErrorTextRole: return feedback.value("errorText").toString(); case ErrorTextRole: return feedback.value("errorText").toString();
case PendingTextRole: return feedback.value("pendingText").toString(QStringLiteral("...")); 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 FadeMsRole: return feedback.value("fadeMs").toInt(5000);
case StatusRole: return rt.status; case StatusRole: return rt.status;
case StatusTextRole: return rt.text; case StatusTextRole: return rt.text;
@@ -88,6 +91,7 @@ QHash<int, QByteArray> ButtonsModel::roleNames() const
{SuccessTextRole, "successText"}, {SuccessTextRole, "successText"},
{ErrorTextRole, "errorText"}, {ErrorTextRole, "errorText"},
{PendingTextRole, "pendingText"}, {PendingTextRole, "pendingText"},
{ResponseCheckEnabledRole, "responseCheckEnabled"},
{FadeMsRole, "fadeMs"}, {FadeMsRole, "fadeMs"},
{StatusRole, "status"}, {StatusRole, "status"},
{StatusTextRole, "statusText"}, {StatusTextRole, "statusText"},
+1
View File
@@ -24,6 +24,7 @@ public:
SuccessTextRole, SuccessTextRole,
ErrorTextRole, ErrorTextRole,
PendingTextRole, PendingTextRole,
ResponseCheckEnabledRole,
FadeMsRole, FadeMsRole,
StatusRole, // 0=idle, 1=ok, 2=error, 3=in-progress StatusRole, // 0=idle, 1=ok, 2=error, 3=in-progress
StatusTextRole, 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
python tools/button-test-server.py --host 0.0.0.0 --port 8765 python tools/button-test-server.py --host 0.0.0.0 --port 8765
Примеры URL для кнопок (подставьте IP вашего ПК): Базовые URL (подставьте IP ПК):
http://192.168.1.10:8765/ok http://<ip>:8765/ok
http://192.168.1.10:8765/fail http://<ip>:8765/fail
http://192.168.1.10:8765/btn?bid=01
http://192.168.1.10:8765/echo (тип: http_post, body: ping) Расширенная проверка ответа (responseCheck):
http://192.168.1.10:8765/slow?sec=8 (таймаут кнопки по умолчанию 7 с) 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 from __future__ import annotations
import argparse import argparse
import json import json
import sys import sys
import threading
import time import time
from datetime import datetime from datetime import datetime
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
@@ -27,8 +32,37 @@ def _now() -> str:
return datetime.now().strftime("%H:%M:%S") 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): class ButtonTestHandler(BaseHTTPRequestHandler):
server_version = "ButtonTestHTTP/1.0" server_version = "ButtonTestHTTP/1.1"
def log_message(self, fmt: str, *args) -> None: def log_message(self, fmt: str, *args) -> None:
print(f"[{_now()}] {self.address_string()} {fmt % args}", flush=True) print(f"[{_now()}] {self.address_string()} {fmt % args}", flush=True)
@@ -38,81 +72,182 @@ class ButtonTestHandler(BaseHTTPRequestHandler):
self.send_response(code) self.send_response(code)
self.send_header("Content-Type", content_type) self.send_header("Content-Type", content_type)
self.send_header("Content-Length", str(len(data))) self.send_header("Content-Length", str(len(data)))
self.send_header("Access-Control-Allow-Origin", "*")
self.end_headers() self.end_headers()
self.wfile.write(data) 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: def _read_body(self) -> bytes:
length = int(self.headers.get("Content-Length", 0)) length = int(self.headers.get("Content-Length", 0))
return self.rfile.read(length) if length else b"" return self.rfile.read(length) if length else b""
def do_GET(self) -> None: def _qs(self) -> dict:
path = urlparse(self.path).path return parse_qs(urlparse(self.path).query)
qs = parse_qs(urlparse(self.path).query)
def _handle(self, path: str, qs: dict, method: str, body_text: str = "") -> bool:
if path in ("", "/"): if path in ("", "/"):
self._send(200, HELP_TEXT) self._send(200, HELP_TEXT)
return return True
if path == "/ok": if path == "/ok":
self._send(200, "OK\n") self._send(200, "OK\n")
return return True
if path == "/fail": if path == "/fail":
self._send(500, "server error\n") self._send(500, "server error\n")
return return True
if path == "/btn": if path == "/btn":
bid = (qs.get("bid") or ["?"])[0] bid = (qs.get("bid") or ["?"])[0]
payload = {"status": "ok", "bid": bid, "ts": time.time()} self._send_json(200, {"status": "ok", "bid": bid, "ts": time.time()})
self._send(200, json.dumps(payload, ensure_ascii=False), "application/json") return True
return
if path == "/slow": if path == "/slow":
sec = float((qs.get("sec") or ["3"])[0]) sec = float((qs.get("sec") or ["3"])[0])
sec = max(0.0, min(sec, 60.0)) sec = max(0.0, min(sec, 60.0))
time.sleep(sec) time.sleep(sec)
self._send(200, f"slept {sec:g}s\n") self._send(200, f"slept {sec:g}s\n")
return return True
if path == "/redirect": if path == "/redirect":
self.send_response(302) self.send_response(302)
self.send_header("Location", "/ok") self.send_header("Location", "/ok")
self.end_headers() self.end_headers()
return return True
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
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")
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
return False
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") self._send(404, f"not found: {path}\n")
def do_POST(self) -> None: def do_POST(self) -> None:
path = urlparse(self.path).path path = urlparse(self.path).path
raw = self._read_body() body_text = self._read_body().decode("utf-8", errors="replace")
text = raw.decode("utf-8", errors="replace") if not self._handle(path, self._qs(), "POST", body_text):
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 path == "/fail":
self._send(500, "post failed\n")
return
self._send(404, f"not found: {path}\n") self._send(404, f"not found: {path}\n")
HELP_TEXT = """ButtonTask test server HELP_TEXT = """ButtonTask test server — сценарии проверки
GET /ok -> 200 (зелёная обводка) === Без responseCheck (только HTTP) ===
GET /fail -> 500 (красная обводка) GET /ok -> 200
GET /btn?bid=01 -> 200 JSON (как в примере config.json) GET /fail -> 500
GET /slow?sec=3 -> задержка, проверка таймаута GET /slow?sec=3 -> задержка
GET /redirect -> 302 -> /ok GET /redirect -> 302 -> /ok
POST /echo -> эхо тела запроса GET /httpfail -> 500 + JSON {"success":"ok"}
POST /fail -> 500
=== 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__) 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>") @api_bp.route("/icons/<path:fname>")
@login_required @login_required
@@ -46,12 +130,12 @@ def api_add_button():
"id": data.get("id") or uuid.uuid4().hex, "id": data.get("id") or uuid.uuid4().hex,
"label": data.get("label", "Кнопка"), "label": data.get("label", "Кнопка"),
"iconPath": data.get("iconPath", ""), "iconPath": data.get("iconPath", ""),
"action": { "action": _apply_button_action({
"type": data.get("actionType", "http_get"), "type": data.get("actionType", "http_get"),
"url": data.get("url", ""), "url": data.get("url", ""),
"headers": data.get("headers", {}), "headers": data.get("headers", {}),
"body": data.get("body", ""), "body": data.get("body", ""),
}, }, data),
"feedback": { "feedback": {
"successText": data.get("successText", ""), "successText": data.get("successText", ""),
"errorText": data.get("errorText", "Ошибка"), "errorText": data.get("errorText", "Ошибка"),
@@ -79,12 +163,7 @@ def api_update_button(bid):
b["label"] = data.get("label", b.get("label")) b["label"] = data.get("label", b.get("label"))
b["iconPath"] = data.get("iconPath", b.get("iconPath")) b["iconPath"] = data.get("iconPath", b.get("iconPath"))
action = b.setdefault("action", {}) action = b.setdefault("action", {})
action["type"] = data.get("actionType", action.get("type", "http_get")) _apply_button_action(action, data)
action["url"] = data.get("url", action.get("url", ""))
if "headers" in data:
action["headers"] = data["headers"]
if "body" in data:
action["body"] = data["body"]
feedback = b.setdefault("feedback", {}) feedback = b.setdefault("feedback", {})
feedback["successText"] = data.get("successText", feedback.get("successText", "")) feedback["successText"] = data.get("successText", feedback.get("successText", ""))
feedback["errorText"] = data.get("errorText", feedback.get("errorText", "Ошибка")) 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 dlg = $("#btn-dialog");
const form = $("#btn-form"); 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() { function syncHoldRow() {
const mode = $("#b-trigger-mode").value; const mode = $("#b-trigger-mode").value;
$("#b-hold-row").style.display = mode === "click" ? "none" : ""; $("#b-hold-row").style.display = mode === "click" ? "none" : "";
@@ -181,6 +268,7 @@ function openDialogFor(btn) {
$("#b-color").value = btn?.color || "#7b007b"; $("#b-color").value = btn?.color || "#7b007b";
$("#b-trigger-mode").value = btn?.trigger?.mode || "hold"; $("#b-trigger-mode").value = btn?.trigger?.mode || "hold";
$("#b-hold").value = btn?.trigger?.holdMs || 800; $("#b-hold").value = btn?.trigger?.holdMs || 800;
fillResponseCheckForm(btn?.action?.responseCheck);
syncHoldRow(); syncHoldRow();
dlg.showModal(); dlg.showModal();
} }
@@ -223,6 +311,7 @@ form?.addEventListener("submit", async (e) => {
color: $("#b-color").value, color: $("#b-color").value,
triggerMode: $("#b-trigger-mode").value, triggerMode: $("#b-trigger-mode").value,
holdMs: parseInt($("#b-hold").value, 10), holdMs: parseInt($("#b-hold").value, 10),
responseCheck: readResponseCheckFromForm(),
}; };
if (id) await api("PUT", `/api/buttons/${encodeURIComponent(id)}`, payload); if (id) await api("PUT", `/api/buttons/${encodeURIComponent(id)}`, payload);
else await api("POST", "/api/buttons", payload); else await api("POST", "/api/buttons", payload);
@@ -325,17 +414,22 @@ function renderButtonLog(entries) {
return; return;
} }
body.innerHTML = entries.map(e => { body.innerHTML = entries.map(e => {
const ok = e.ok; const outcome = e.outcome || (e.ok ? "ok" : "error");
const result = ok const ok = outcome === "ok";
? (e.http ? `OK ${e.http}` : "OK") + (e.message ? `${e.message}` : "") const pending = outcome === "pending";
: (e.http ? `ERR ${e.http}` : "ERR") + (e.message ? `${e.message}` : ""); 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 ` return `
<tr> <tr>
<td>${e.ts || "—"}</td> <td>${e.ts || "—"}</td>
<td>${e.label || "—"}</td> <td>${e.label || "—"}</td>
<td>${e.method || "—"}</td> <td>${e.method || "—"}</td>
<td class="log-path" title="${e.url || ""}">${e.url || "—"}</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> <td>${e.ms != null ? e.ms : "—"}</td>
</tr>`; </tr>`;
}).join(""); }).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-path { max-width: 280px; overflow: hidden; text-overflow: ellipsis; }
.req-log .log-err { color: #f87171; } .req-log .log-err { color: #f87171; }
.req-log .log-ok { color: #4ade80; } .req-log .log-ok { color: #4ade80; }
.req-log .log-wait { color: #fcd34d; }
.req-log .empty-cell { color: var(--muted); text-align: center; } .req-log .empty-cell { color: var(--muted); text-align: center; }
/* ---------- Dialog ---------- */ /* ---------- Dialog ---------- */
@@ -361,13 +362,41 @@ dialog {
border-radius: var(--radius); border-radius: var(--radius);
color: var(--text); color: var(--text);
padding: 22px; padding: 22px;
min-width: 360px; min-width: min(96vw, 760px);
} }
dialog::backdrop { background: rgba(0,0,0,0.6); } dialog::backdrop { background: rgba(0,0,0,0.6); }
dialog form { display: flex; flex-direction: column; gap: 10px; } dialog form { display: flex; flex-direction: column; gap: 10px; }
dialog h3 { margin: 0 0 6px; } dialog h3 { margin: 0 0 6px; }
dialog menu { display: flex; justify-content: flex-end; gap: 8px; padding: 0; margin: 14px 0 0; } 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 ---------- */
.toast { .toast {
position: fixed; bottom: 24px; right: 24px; 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> мс <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"> <input type="range" id="b-hold" min="200" max="3000" step="50" value="800">
</label> </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> <menu>
<button value="cancel">Отмена</button> <button value="cancel">Отмена</button>
<button id="b-save" value="ok" type="submit" class="primary">Сохранить</button> <button id="b-save" value="ok" type="submit" class="primary">Сохранить</button>
Binary file not shown.