fixed default network
This commit is contained in:
@@ -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.
Vendored
BIN
Binary file not shown.
@@ -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. [ ] Проверка на панели + запись в журнале на вкладке «Система».
|
||||
@@ -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
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"},
|
||||
|
||||
@@ -24,6 +24,7 @@ public:
|
||||
SuccessTextRole,
|
||||
ErrorTextRole,
|
||||
PendingTextRole,
|
||||
ResponseCheckEnabledRole,
|
||||
FadeMsRole,
|
||||
StatusRole, // 0=idle, 1=ok, 2=error, 3=in-progress
|
||||
StatusTextRole,
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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
@@ -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("");
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 < 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.
Reference in New Issue
Block a user