init
@@ -0,0 +1,75 @@
|
|||||||
|
# --- CMake / Qt build trees ---
|
||||||
|
/build/
|
||||||
|
/build-*/
|
||||||
|
/build-test/
|
||||||
|
/out/
|
||||||
|
/cmake-build-*/
|
||||||
|
|
||||||
|
# --- CMake generated files (outside build dirs) ---
|
||||||
|
CMakeCache.txt
|
||||||
|
CMakeFiles/
|
||||||
|
cmake_install.cmake
|
||||||
|
install_manifest.txt
|
||||||
|
Makefile
|
||||||
|
*.ninja
|
||||||
|
.ninja_deps
|
||||||
|
.ninja_log
|
||||||
|
compile_commands.json
|
||||||
|
CTestTestfile.cmake
|
||||||
|
Testing/
|
||||||
|
|
||||||
|
# --- Qt Creator ---
|
||||||
|
CMakeLists.txt.user
|
||||||
|
CMakeLists.txt.user.*
|
||||||
|
*.autosave
|
||||||
|
*.pro.user
|
||||||
|
*.pro.user.*
|
||||||
|
|
||||||
|
# --- Compiled binaries / libs ---
|
||||||
|
*.exe
|
||||||
|
*.dll
|
||||||
|
*.so
|
||||||
|
*.so.*
|
||||||
|
*.dylib
|
||||||
|
*.a
|
||||||
|
*.lib
|
||||||
|
*.obj
|
||||||
|
*.o
|
||||||
|
*.pdb
|
||||||
|
*.ilk
|
||||||
|
*.exp
|
||||||
|
|
||||||
|
# --- Qt MOC / RCC / UIC ---
|
||||||
|
*_autogen/
|
||||||
|
moc_*.cpp
|
||||||
|
mocs_compilation.cpp
|
||||||
|
qrc_*.cpp
|
||||||
|
ui_*.h
|
||||||
|
|
||||||
|
# --- Deployment bundles (windeployqt / manual copy) ---
|
||||||
|
/Deploy/
|
||||||
|
/WinDeploy/
|
||||||
|
/Dep/
|
||||||
|
|
||||||
|
# --- Python (webconfig) ---
|
||||||
|
__pycache__/
|
||||||
|
*.py[cod]
|
||||||
|
*.pyo
|
||||||
|
.Python
|
||||||
|
.venv/
|
||||||
|
venv/
|
||||||
|
env/
|
||||||
|
|
||||||
|
# --- Secrets / local runtime ---
|
||||||
|
config/.webconfig.secret
|
||||||
|
**/.webconfig.secret
|
||||||
|
|
||||||
|
# --- IDE / OS ---
|
||||||
|
.vs/
|
||||||
|
.vscode/
|
||||||
|
.idea/
|
||||||
|
*.swp
|
||||||
|
*~
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
|
desktop.ini
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
cmake_minimum_required(VERSION 3.10)
|
||||||
|
|
||||||
|
project(ButtonTask VERSION 2.0 LANGUAGES CXX)
|
||||||
|
|
||||||
|
set(CMAKE_CXX_STANDARD 14)
|
||||||
|
set(CMAKE_CXX_STANDARD_REQUIRED ON)
|
||||||
|
set(CMAKE_AUTOMOC ON)
|
||||||
|
set(CMAKE_AUTORCC ON)
|
||||||
|
|
||||||
|
find_package(Qt5 5.9 REQUIRED COMPONENTS Core Quick Qml Network)
|
||||||
|
|
||||||
|
set(SOURCES
|
||||||
|
main.cpp
|
||||||
|
src/configmanager.cpp
|
||||||
|
src/configmanager.h
|
||||||
|
src/buttonsmodel.cpp
|
||||||
|
src/buttonsmodel.h
|
||||||
|
src/buttoncontroller.cpp
|
||||||
|
src/buttoncontroller.h
|
||||||
|
src/settingscontainer.cpp
|
||||||
|
src/settingscontainer.h
|
||||||
|
src/systeminfo.cpp
|
||||||
|
src/systeminfo.h
|
||||||
|
)
|
||||||
|
|
||||||
|
set(RESOURCES
|
||||||
|
resources/qml.qrc
|
||||||
|
resources/icons.qrc
|
||||||
|
)
|
||||||
|
|
||||||
|
add_executable(ButtonTask ${SOURCES} ${RESOURCES})
|
||||||
|
|
||||||
|
target_include_directories(ButtonTask PRIVATE
|
||||||
|
${CMAKE_CURRENT_SOURCE_DIR}/src
|
||||||
|
)
|
||||||
|
|
||||||
|
target_link_libraries(ButtonTask PRIVATE
|
||||||
|
Qt5::Core
|
||||||
|
Qt5::Quick
|
||||||
|
Qt5::Qml
|
||||||
|
Qt5::Network
|
||||||
|
)
|
||||||
|
|
||||||
|
include(GNUInstallDirs)
|
||||||
|
|
||||||
|
install(TARGETS ButtonTask
|
||||||
|
RUNTIME DESTINATION ${CMAKE_INSTALL_BINDIR}
|
||||||
|
)
|
||||||
|
|
||||||
|
install(FILES config/config.json
|
||||||
|
DESTINATION ${CMAKE_INSTALL_BINDIR}/config
|
||||||
|
)
|
||||||
|
|
||||||
|
install(DIRECTORY resources/default-icons/
|
||||||
|
DESTINATION ${CMAKE_INSTALL_BINDIR}/icons
|
||||||
|
FILES_MATCHING PATTERN "*.png" PATTERN "*.jpg"
|
||||||
|
)
|
||||||
|
|
||||||
|
install(DIRECTORY webconfig/
|
||||||
|
DESTINATION share/buttontask/webconfig
|
||||||
|
)
|
||||||
|
|
||||||
|
# Privileged helper scripts (network + OTA). Installed with execute perms so
|
||||||
|
# they work even when the source tree was checked out on a non-POSIX host.
|
||||||
|
install(PROGRAMS
|
||||||
|
webconfig/scripts/bt-netconfig
|
||||||
|
webconfig/scripts/bt-update
|
||||||
|
webconfig/scripts/bt-service
|
||||||
|
webconfig/scripts/make-package.sh
|
||||||
|
DESTINATION share/buttontask/scripts
|
||||||
|
)
|
||||||
|
|
||||||
|
install(FILES webconfig/scripts/buttontask.sudoers
|
||||||
|
DESTINATION share/buttontask/scripts
|
||||||
|
)
|
||||||
@@ -0,0 +1,217 @@
|
|||||||
|
# ButtonTask v2
|
||||||
|
|
||||||
|
Configurable touch-screen button panel for embedded Linux (Qt5 + QML), with a
|
||||||
|
companion Flask web configurator. Both share a single JSON config file.
|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
- Dynamic list of buttons (1..N), each fires HTTP GET/POST on tap
|
||||||
|
- Layout: grid (configurable column count) or list
|
||||||
|
- Configurable background: animated gradient, solid color or image
|
||||||
|
- Configurable feedback ring: color and width of the OK / error glow around buttons
|
||||||
|
- In-app settings dialog (tabbed pages): CRUD buttons, drag-and-drop reorder,
|
||||||
|
layout/background/feedback/password, Ethernet configuration and update info
|
||||||
|
- Web configurator (Flask blueprints + tabbed UI + SortableJS): same operations
|
||||||
|
remotely, plus icon upload, Ethernet configuration and OTA updates
|
||||||
|
- Ethernet configuration via NetworkManager (`nmcli`) from the web UI
|
||||||
|
- OTA software update with automatic rollback on a failed launch (health-check)
|
||||||
|
- Hot reload: app picks up config changes via `QFileSystemWatcher`
|
||||||
|
|
||||||
|
## Layout
|
||||||
|
|
||||||
|
```
|
||||||
|
ButtonTask/
|
||||||
|
CMakeLists.txt Qt5 build
|
||||||
|
main.cpp
|
||||||
|
src/ C++ sources (ConfigManager, ButtonsModel, ButtonController,
|
||||||
|
SettingsContainer, SystemInfo)
|
||||||
|
qml/ Main.qml, BackgroundLayer.qml, ButtonDelegate.qml, SettingsDialog.qml
|
||||||
|
components/ BackHeader.qml, LabeledSlider.qml
|
||||||
|
settings/ MainPage / ButtonsPage / LayoutPage / BackgroundPage /
|
||||||
|
FeedbackPage / NetworkInfoPage / UpdatePage / ButtonEditorDialog
|
||||||
|
resources/ qml.qrc, icons.qrc
|
||||||
|
config/config.json default config (shipped next to the binary)
|
||||||
|
webconfig/ Flask service (modular):
|
||||||
|
app.py application factory + /healthz
|
||||||
|
config_store.py load/save/defaults (in sync with C++ ensureDefaults)
|
||||||
|
auth.py login/logout/session
|
||||||
|
api.py buttons/layout/background/feedback/settings/icons
|
||||||
|
network.py Ethernet via nmcli
|
||||||
|
updater.py OTA upload + status
|
||||||
|
paths.py install layout helpers
|
||||||
|
templates/ static/ *.service, requirements.txt
|
||||||
|
scripts/ bt-netconfig, bt-update, bt-service, buttontask.sudoers,
|
||||||
|
make-package.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
The Qt app reads the config from (in order of priority):
|
||||||
|
1. `--config <path>` command-line argument
|
||||||
|
2. `BUTTONTASK_CONFIG` environment variable
|
||||||
|
3. `<applicationDir>/config/config.json` (default)
|
||||||
|
|
||||||
|
The Flask service reads the same paths.
|
||||||
|
|
||||||
|
## Building (embedded Linux, Qt5)
|
||||||
|
|
||||||
|
```sh
|
||||||
|
sudo apt install qtbase5-dev qtdeclarative5-dev \
|
||||||
|
qml-module-qtquick-controls2 qml-module-qtquick-layouts \
|
||||||
|
qml-module-qtgraphicaleffects qml-module-qtqml-models2 \
|
||||||
|
cmake build-essential
|
||||||
|
|
||||||
|
mkdir build && cd build
|
||||||
|
cmake -DCMAKE_BUILD_TYPE=Release ..
|
||||||
|
make -j$(nproc)
|
||||||
|
sudo make install
|
||||||
|
```
|
||||||
|
|
||||||
|
Binary lands in `/usr/local/bin/ButtonTask`. To run on a framebuffer/EGL device:
|
||||||
|
```sh
|
||||||
|
QT_QPA_PLATFORM=eglfs ButtonTask --config /opt/buttontask/config/config.json
|
||||||
|
```
|
||||||
|
|
||||||
|
## Web configurator
|
||||||
|
|
||||||
|
```sh
|
||||||
|
cd webconfig
|
||||||
|
pip3 install -r requirements.txt
|
||||||
|
BUTTONTASK_CONFIG=../config/config.json python3 app.py
|
||||||
|
```
|
||||||
|
Open `http://<device-ip>:8080`. Login: `admin` + the password from `settings.password`.
|
||||||
|
|
||||||
|
## Deployment with systemd (versioned layout)
|
||||||
|
|
||||||
|
The device uses an A/B-style versioned layout so OTA updates can roll back:
|
||||||
|
|
||||||
|
```
|
||||||
|
/opt/buttontask/
|
||||||
|
versions/<ver>/ ButtonTask + webconfig/ (one dir per deployed version)
|
||||||
|
current -> versions/<ver> (active version symlink)
|
||||||
|
config/ icons/ shared, never replaced by an update
|
||||||
|
run/ qt.alive heartbeat, update-status.json
|
||||||
|
scripts/ bt-netconfig, bt-update, bt-service (root-owned)
|
||||||
|
```
|
||||||
|
|
||||||
|
First install:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
sudo useradd -r -s /bin/false buttontask
|
||||||
|
sudo mkdir -p /opt/buttontask/{versions/2.0,config,icons,run,scripts}
|
||||||
|
sudo cp build/ButtonTask /opt/buttontask/versions/2.0/
|
||||||
|
sudo cp -r webconfig /opt/buttontask/versions/2.0/
|
||||||
|
sudo ln -sfn /opt/buttontask/versions/2.0 /opt/buttontask/current
|
||||||
|
sudo cp config/config.json /opt/buttontask/config/
|
||||||
|
|
||||||
|
# privileged helpers (root-owned) + sudoers whitelist
|
||||||
|
sudo install -m 0755 webconfig/scripts/bt-netconfig webconfig/scripts/bt-update \
|
||||||
|
webconfig/scripts/bt-service /opt/buttontask/scripts/
|
||||||
|
sudo install -m 0440 webconfig/scripts/buttontask.sudoers /etc/sudoers.d/buttontask
|
||||||
|
sudo visudo -cf /etc/sudoers.d/buttontask
|
||||||
|
|
||||||
|
sudo chown -R buttontask:buttontask /opt/buttontask
|
||||||
|
sudo chown root:root /opt/buttontask/scripts/bt-* # scripts must be root-owned
|
||||||
|
|
||||||
|
sudo cp webconfig/buttontask.service /etc/systemd/system/
|
||||||
|
sudo cp webconfig/buttontask-web.service /etc/systemd/system/
|
||||||
|
sudo systemctl daemon-reload
|
||||||
|
sudo systemctl enable --now buttontask buttontask-web
|
||||||
|
```
|
||||||
|
|
||||||
|
## Network configuration (Ethernet)
|
||||||
|
|
||||||
|
Open the web UI → tab **Сеть**. It reads the live state via `nmcli` and can
|
||||||
|
apply DHCP or a static IP/prefix/gateway/DNS. The change is performed by the
|
||||||
|
root helper `bt-netconfig` (whitelisted in sudoers); the unprivileged web
|
||||||
|
service never touches the network directly. The same settings can also be
|
||||||
|
applied from the device itself via the in-app **Сеть** page (it calls the same
|
||||||
|
`bt-netconfig` helper through sudo, so the Qt UI must run as the `buttontask`
|
||||||
|
user covered by the sudoers rule).
|
||||||
|
|
||||||
|
## OTA software update
|
||||||
|
|
||||||
|
Build a package and upload it from the web UI → tab **Обновление**:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
./webconfig/scripts/make-package.sh 2.1 build dist
|
||||||
|
# -> dist/buttontask-2.1.tar.gz (+ sha256 to paste for integrity)
|
||||||
|
```
|
||||||
|
|
||||||
|
On upload, `bt-update` (detached, root) extracts the package into
|
||||||
|
`versions/<ver>`, flips the `current` symlink, restarts both services, then runs
|
||||||
|
a health-check: the web `/healthz` must answer, the Qt UI heartbeat
|
||||||
|
(`run/qt.alive`) must be fresh, and the `buttontask` unit must be active within
|
||||||
|
`BUTTONTASK_HEALTH_TIMEOUT` (default 30s). If any check fails it automatically
|
||||||
|
switches `current` back to the previous version and restarts. Progress is shown
|
||||||
|
live and is also readable in the app's **Обновление** page.
|
||||||
|
|
||||||
|
## JSON config schema
|
||||||
|
|
||||||
|
```jsonc
|
||||||
|
{
|
||||||
|
"version": 2,
|
||||||
|
"layout": {
|
||||||
|
"mode": "grid", // "grid" | "list"
|
||||||
|
"columns": 2, // grid only
|
||||||
|
"spacing": 10,
|
||||||
|
"showLabels": true
|
||||||
|
},
|
||||||
|
"background": {
|
||||||
|
"type": "gradient", // "gradient" | "solid" | "image"
|
||||||
|
"color1": "#00007b",
|
||||||
|
"color2": "#7b007b",
|
||||||
|
"animated": true,
|
||||||
|
"imagePath": "" // filename inside iconsDir
|
||||||
|
},
|
||||||
|
"feedback": {
|
||||||
|
"okColor": "#00cc44", // ring/glow color on success
|
||||||
|
"errorColor": "#cc2200", // ring/glow color on error
|
||||||
|
"okWidth": 6, // ring width (px) on success
|
||||||
|
"errorWidth": 6, // ring width (px) on error
|
||||||
|
"glowRadius": 20 // glow radius (0 disables the glow)
|
||||||
|
},
|
||||||
|
"settings": {
|
||||||
|
"darkMode": true,
|
||||||
|
"password": "admin", // shared with web auth
|
||||||
|
"kioskMode": false,
|
||||||
|
"iconsDir": "./icons", // dir for user icons & background images
|
||||||
|
"webPort": 8080
|
||||||
|
},
|
||||||
|
"buttons": [
|
||||||
|
{
|
||||||
|
"id": "uuid",
|
||||||
|
"label": "Кнопка",
|
||||||
|
"iconPath": "cleaner1.png", // relative to iconsDir, or absolute
|
||||||
|
"action": {
|
||||||
|
"type": "http_get", // "http_get" | "http_post"
|
||||||
|
"url": "http://server/btn",
|
||||||
|
"headers": {}, // e.g. {"Authorization": "Bearer <token>"}
|
||||||
|
"body": "",
|
||||||
|
"timeoutMs": 7000, // optional request timeout (default 7000)
|
||||||
|
"acceptAnyResponse": false // optional: true = any HTTP reply is success
|
||||||
|
},
|
||||||
|
"feedback": {
|
||||||
|
"successText": "Отправлено",
|
||||||
|
"errorText": "Ошибка",
|
||||||
|
"fadeMs": 5000
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
|
||||||
|
- Both Qt and Flask write the config atomically (`QSaveFile` / `os.replace`).
|
||||||
|
- Qt watches the file via `QFileSystemWatcher` and reloads automatically when
|
||||||
|
the web configurator saves changes.
|
||||||
|
- Button success criteria: a request is **green** on a 1xx/2xx/3xx HTTP reply
|
||||||
|
(redirects are followed) and **red** on 4xx/5xx or a transport failure
|
||||||
|
(timeout / connection refused / DNS). Set `action.acceptAnyResponse: true` to
|
||||||
|
treat *any* received HTTP reply as success (only timeouts/refused fail), and
|
||||||
|
`action.timeoutMs` to tune the timeout. Add an API key/token via
|
||||||
|
`action.headers` if the target endpoint requires one.
|
||||||
|
- Web configurator endpoints are all session-protected (`@login_required` after
|
||||||
|
password login); only `/healthz` is public (it exposes just `ok` + version,
|
||||||
|
used by the OTA rollback health-check).
|
||||||
|
- Default credentials are `admin` / `admin` — change on first run.
|
||||||
|
- v1 (Android/RS485/voice) was removed; only the icon set was kept.
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
{
|
||||||
|
"version": 2,
|
||||||
|
"layout": {
|
||||||
|
"mode": "list",
|
||||||
|
"columns": 1,
|
||||||
|
"spacing": 10,
|
||||||
|
"showLabels": true
|
||||||
|
},
|
||||||
|
"background": {
|
||||||
|
"type": "gradient",
|
||||||
|
"color1": "#00007b",
|
||||||
|
"color2": "#7b007b",
|
||||||
|
"animated": true,
|
||||||
|
"imagePath": ""
|
||||||
|
},
|
||||||
|
"feedback": {
|
||||||
|
"okColor": "#00cc44",
|
||||||
|
"errorColor": "#cc2200",
|
||||||
|
"okWidth": 3,
|
||||||
|
"errorWidth": 2,
|
||||||
|
"glowRadius": 28
|
||||||
|
},
|
||||||
|
"settings": {
|
||||||
|
"darkMode": true,
|
||||||
|
"password": "admin",
|
||||||
|
"kioskMode": false,
|
||||||
|
"iconsDir": "./icons",
|
||||||
|
"webPort": 8080
|
||||||
|
},
|
||||||
|
"buttons": [
|
||||||
|
{
|
||||||
|
"id": "example-1",
|
||||||
|
"label": "Пример",
|
||||||
|
"iconPath": "cleaner1.png",
|
||||||
|
"action": {
|
||||||
|
"type": "http_get",
|
||||||
|
"url": "http://localhost/btn?bid=01",
|
||||||
|
"headers": {},
|
||||||
|
"body": ""
|
||||||
|
},
|
||||||
|
"feedback": {
|
||||||
|
"successText": "Отправлено",
|
||||||
|
"errorText": "Ошибка",
|
||||||
|
"fadeMs": 5000
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "cbd6791cdcb74a58861554b30239c65e",
|
||||||
|
"label": "dfg",
|
||||||
|
"iconPath": "cogwheel1.png",
|
||||||
|
"action": {
|
||||||
|
"type": "http_get",
|
||||||
|
"url": "http://example.com",
|
||||||
|
"headers": {},
|
||||||
|
"body": ""
|
||||||
|
},
|
||||||
|
"feedback": {
|
||||||
|
"successText": "",
|
||||||
|
"errorText": "",
|
||||||
|
"fadeMs": 5000
|
||||||
|
},
|
||||||
|
"color": "#ab26ab"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
After Width: | Height: | Size: 28 KiB |
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 39 KiB |
|
After Width: | Height: | Size: 16 KiB |
|
After Width: | Height: | Size: 14 KiB |
|
After Width: | Height: | Size: 50 KiB |
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 14 KiB |
|
After Width: | Height: | Size: 13 KiB |
|
After Width: | Height: | Size: 9.6 KiB |
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 10 KiB |
|
After Width: | Height: | Size: 2.5 KiB |
|
After Width: | Height: | Size: 2.5 KiB |
|
After Width: | Height: | Size: 6.8 KiB |
|
After Width: | Height: | Size: 7.1 KiB |
|
After Width: | Height: | Size: 19 KiB |
|
After Width: | Height: | Size: 21 KiB |
|
After Width: | Height: | Size: 51 KiB |
|
After Width: | Height: | Size: 9.9 KiB |
@@ -0,0 +1,59 @@
|
|||||||
|
#include <QGuiApplication>
|
||||||
|
#include <QQmlApplicationEngine>
|
||||||
|
#include <QQmlContext>
|
||||||
|
#include <QCommandLineParser>
|
||||||
|
#include <QFileInfo>
|
||||||
|
#include <QDir>
|
||||||
|
#include <QDebug>
|
||||||
|
|
||||||
|
#include "configmanager.h"
|
||||||
|
#include "buttonsmodel.h"
|
||||||
|
#include "buttoncontroller.h"
|
||||||
|
#include "settingscontainer.h"
|
||||||
|
#include "systeminfo.h"
|
||||||
|
|
||||||
|
int main(int argc, char *argv[])
|
||||||
|
{
|
||||||
|
qputenv("QT_IM_MODULE", "qtvirtualkeyboard");
|
||||||
|
QGuiApplication app(argc, argv);
|
||||||
|
QCoreApplication::setApplicationName("ButtonTask");
|
||||||
|
QCoreApplication::setOrganizationName("ButtonTask");
|
||||||
|
QCoreApplication::setApplicationVersion("2.0");
|
||||||
|
|
||||||
|
QCommandLineParser parser;
|
||||||
|
parser.setApplicationDescription("ButtonTask v2 - configurable button panel");
|
||||||
|
parser.addHelpOption();
|
||||||
|
parser.addVersionOption();
|
||||||
|
QCommandLineOption configOpt(QStringList() << "c" << "config",
|
||||||
|
"Path to config.json",
|
||||||
|
"path");
|
||||||
|
parser.addOption(configOpt);
|
||||||
|
parser.process(app);
|
||||||
|
|
||||||
|
QString configPath = parser.value(configOpt);
|
||||||
|
if (configPath.isEmpty())
|
||||||
|
configPath = qEnvironmentVariable("BUTTONTASK_CONFIG");
|
||||||
|
if (configPath.isEmpty())
|
||||||
|
configPath = QCoreApplication::applicationDirPath() + "/config/config.json";
|
||||||
|
|
||||||
|
qInfo() << "Using config:" << configPath;
|
||||||
|
|
||||||
|
ConfigManager config(configPath);
|
||||||
|
ButtonsModel model(&config);
|
||||||
|
ButtonController controller(&config, &model);
|
||||||
|
SettingsContainer settings(&config);
|
||||||
|
SystemInfo systemInfo;
|
||||||
|
|
||||||
|
QQmlApplicationEngine engine;
|
||||||
|
engine.rootContext()->setContextProperty("config", &config);
|
||||||
|
engine.rootContext()->setContextProperty("buttonsModel", &model);
|
||||||
|
engine.rootContext()->setContextProperty("btnController", &controller);
|
||||||
|
engine.rootContext()->setContextProperty("settingsContainer", &settings);
|
||||||
|
engine.rootContext()->setContextProperty("systemInfo", &systemInfo);
|
||||||
|
|
||||||
|
engine.load(QUrl(QStringLiteral("qrc:/qml/Main.qml")));
|
||||||
|
if (engine.rootObjects().isEmpty())
|
||||||
|
return -1;
|
||||||
|
|
||||||
|
return app.exec();
|
||||||
|
}
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
import QtQuick 2.12
|
||||||
|
import QtGraphicalEffects 1.12
|
||||||
|
|
||||||
|
Item {
|
||||||
|
id: root
|
||||||
|
|
||||||
|
property var bg: settingsContainer
|
||||||
|
property int bx1: 0
|
||||||
|
property int by1: 0
|
||||||
|
property int br1: 200
|
||||||
|
property int bx2: 0
|
||||||
|
property int by2: 0
|
||||||
|
property int br2: 200
|
||||||
|
|
||||||
|
Rectangle {
|
||||||
|
anchors.fill: parent
|
||||||
|
color: "black"
|
||||||
|
}
|
||||||
|
|
||||||
|
// Solid color
|
||||||
|
Rectangle {
|
||||||
|
anchors.fill: parent
|
||||||
|
visible: settingsContainer.backgroundType === "solid"
|
||||||
|
color: settingsContainer.backgroundColor1
|
||||||
|
}
|
||||||
|
|
||||||
|
// Image background
|
||||||
|
Image {
|
||||||
|
anchors.fill: parent
|
||||||
|
visible: settingsContainer.backgroundType === "image" && source != ""
|
||||||
|
fillMode: Image.PreserveAspectCrop
|
||||||
|
source: {
|
||||||
|
var p = settingsContainer.backgroundImage
|
||||||
|
if (!p) return ""
|
||||||
|
if (p.indexOf("file://") === 0 || p.indexOf("qrc:") === 0 || p.indexOf("http") === 0) return p
|
||||||
|
if (p.charAt(0) === "/" || /^[A-Za-z]:[\\\/]/.test(p)) return "file://" + p
|
||||||
|
return "file://" + settingsContainer.iconsDir + "/" + p
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Animated gradient (default)
|
||||||
|
Item {
|
||||||
|
anchors.fill: parent
|
||||||
|
visible: settingsContainer.backgroundType === "gradient"
|
||||||
|
|
||||||
|
RadialGradient {
|
||||||
|
anchors.fill: parent
|
||||||
|
horizontalOffset: bx1
|
||||||
|
verticalOffset: by1
|
||||||
|
horizontalRadius: br1
|
||||||
|
verticalRadius: br1
|
||||||
|
gradient: Gradient {
|
||||||
|
GradientStop { position: 0.0; color: settingsContainer.backgroundColor1 }
|
||||||
|
GradientStop { position: 1.0; color: "transparent" }
|
||||||
|
}
|
||||||
|
Behavior on horizontalOffset { NumberAnimation { duration: 5000; easing.type: Easing.Linear } }
|
||||||
|
Behavior on verticalOffset { NumberAnimation { duration: 5000; easing.type: Easing.Linear } }
|
||||||
|
Behavior on horizontalRadius { NumberAnimation { duration: 5000; easing.type: Easing.Linear } }
|
||||||
|
Behavior on verticalRadius { NumberAnimation { duration: 5000; easing.type: Easing.Linear } }
|
||||||
|
}
|
||||||
|
RadialGradient {
|
||||||
|
anchors.fill: parent
|
||||||
|
horizontalOffset: bx2
|
||||||
|
verticalOffset: by2
|
||||||
|
horizontalRadius: br2
|
||||||
|
verticalRadius: br2
|
||||||
|
gradient: Gradient {
|
||||||
|
GradientStop { position: 0.0; color: settingsContainer.backgroundColor2 }
|
||||||
|
GradientStop { position: 1.0; color: "transparent" }
|
||||||
|
}
|
||||||
|
Behavior on horizontalOffset { NumberAnimation { duration: 5000; easing.type: Easing.Linear } }
|
||||||
|
Behavior on verticalOffset { NumberAnimation { duration: 5000; easing.type: Easing.Linear } }
|
||||||
|
Behavior on horizontalRadius { NumberAnimation { duration: 5000; easing.type: Easing.Linear } }
|
||||||
|
Behavior on verticalRadius { NumberAnimation { duration: 5000; easing.type: Easing.Linear } }
|
||||||
|
}
|
||||||
|
|
||||||
|
Timer {
|
||||||
|
running: settingsContainer.backgroundAnimated && settingsContainer.backgroundType === "gradient"
|
||||||
|
repeat: true
|
||||||
|
interval: 2500
|
||||||
|
triggeredOnStart: true
|
||||||
|
onTriggered: {
|
||||||
|
bx1 = Math.random() * root.width - root.width / 2
|
||||||
|
by1 = Math.random() * root.height - root.height / 2
|
||||||
|
br1 = Math.random() * 300 + 200
|
||||||
|
bx2 = Math.random() * root.width - root.width / 2
|
||||||
|
by2 = Math.random() * root.height - root.height / 2
|
||||||
|
br2 = Math.random() * 300 + 200
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,100 @@
|
|||||||
|
import QtQuick 2.12
|
||||||
|
import QtQuick.Controls 2.12
|
||||||
|
import QtGraphicalEffects 1.12
|
||||||
|
|
||||||
|
Item {
|
||||||
|
id: delegateRoot
|
||||||
|
|
||||||
|
property bool showLabel: true
|
||||||
|
property bool darkMode: settingsContainer.darkMode
|
||||||
|
|
||||||
|
// Resolved feedback colour/width for the current status (ok / error).
|
||||||
|
readonly property color okColor: settingsContainer.feedbackOkColor
|
||||||
|
readonly property color errorColor: settingsContainer.feedbackErrorColor
|
||||||
|
readonly property color feedbackColor: status === 1 ? okColor
|
||||||
|
: status === 2 ? errorColor
|
||||||
|
: "#ffffff"
|
||||||
|
readonly property int feedbackWidth: status === 1 ? settingsContainer.feedbackOkWidth
|
||||||
|
: status === 2 ? settingsContainer.feedbackErrorWidth
|
||||||
|
: 2
|
||||||
|
|
||||||
|
// Glow: white=in-progress, ok/error use configured colours
|
||||||
|
Rectangle {
|
||||||
|
id: glowRect
|
||||||
|
anchors.centerIn: parent
|
||||||
|
width: circle.width * glowScale
|
||||||
|
height: width
|
||||||
|
radius: width / 2
|
||||||
|
color: delegateRoot.feedbackColor
|
||||||
|
opacity: status === 0 ? 0 : 0.5
|
||||||
|
visible: status !== 0 && settingsContainer.feedbackGlowRadius > 0
|
||||||
|
|
||||||
|
property real glowScale: 1.0
|
||||||
|
|
||||||
|
Behavior on opacity { NumberAnimation { duration: 300 } }
|
||||||
|
Behavior on color { ColorAnimation { duration: 200 } }
|
||||||
|
|
||||||
|
SequentialAnimation on glowScale {
|
||||||
|
running: status === 3
|
||||||
|
loops: Animation.Infinite
|
||||||
|
NumberAnimation { to: 1.4; duration: 700; easing.type: Easing.InOutSine }
|
||||||
|
NumberAnimation { to: 1.0; duration: 700; easing.type: Easing.InOutSine }
|
||||||
|
}
|
||||||
|
NumberAnimation on glowScale {
|
||||||
|
running: status !== 3
|
||||||
|
to: 1.0; duration: 300
|
||||||
|
}
|
||||||
|
|
||||||
|
layer.enabled: true
|
||||||
|
layer.effect: Glow {
|
||||||
|
radius: settingsContainer.feedbackGlowRadius
|
||||||
|
samples: 24
|
||||||
|
color: delegateRoot.feedbackColor
|
||||||
|
transparentBorder: true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Circle button — fixed color from config; only the ring reacts to status.
|
||||||
|
Rectangle {
|
||||||
|
id: circle
|
||||||
|
anchors.centerIn: parent
|
||||||
|
width: Math.min(parent.width, parent.height) * 0.82
|
||||||
|
height: width
|
||||||
|
radius: width / 2
|
||||||
|
color: btnColor || "#7b007b"
|
||||||
|
border.color: status === 1 ? delegateRoot.okColor
|
||||||
|
: status === 2 ? delegateRoot.errorColor
|
||||||
|
: Qt.lighter(btnColor || "#7b007b", 1.5)
|
||||||
|
border.width: status === 1 || status === 2 ? delegateRoot.feedbackWidth : 1
|
||||||
|
Behavior on border.width { NumberAnimation { duration: 200 } }
|
||||||
|
Behavior on border.color { ColorAnimation { duration: 200 } }
|
||||||
|
|
||||||
|
Image {
|
||||||
|
anchors.centerIn: parent
|
||||||
|
width: parent.width * 0.8
|
||||||
|
height: width
|
||||||
|
fillMode: Image.PreserveAspectFit
|
||||||
|
source: iconPath
|
||||||
|
visible: iconPath !== ""
|
||||||
|
asynchronous: true
|
||||||
|
}
|
||||||
|
|
||||||
|
MouseArea {
|
||||||
|
anchors.fill: parent
|
||||||
|
onClicked: btnController.invokeButton(btnId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Text {
|
||||||
|
anchors.top: circle.bottom
|
||||||
|
anchors.topMargin: 4
|
||||||
|
anchors.horizontalCenter: parent.horizontalCenter
|
||||||
|
width: parent.width
|
||||||
|
visible: showLabel
|
||||||
|
text: statusText !== "" ? statusText : label
|
||||||
|
color: darkMode ? "white" : "black"
|
||||||
|
font.pixelSize: Math.max(10, parent.width * 0.1)
|
||||||
|
horizontalAlignment: Text.AlignHCenter
|
||||||
|
elide: Text.ElideRight
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,174 @@
|
|||||||
|
import QtQuick 2.12
|
||||||
|
import QtQuick.Window 2.12
|
||||||
|
import QtQuick.Controls 2.12
|
||||||
|
import QtQuick.Layouts 1.12
|
||||||
|
import QtGraphicalEffects 1.12
|
||||||
|
import QtQuick.VirtualKeyboard 2.4
|
||||||
|
|
||||||
|
Window {
|
||||||
|
id: root
|
||||||
|
visible: true
|
||||||
|
width: 480
|
||||||
|
height: 480
|
||||||
|
title: "ButtonTask"
|
||||||
|
color: "black"
|
||||||
|
visibility: Window.FullScreen
|
||||||
|
|
||||||
|
property bool darkMode: settingsContainer.darkMode
|
||||||
|
|
||||||
|
BackgroundLayer {
|
||||||
|
id: bg
|
||||||
|
anchors.fill: parent
|
||||||
|
}
|
||||||
|
|
||||||
|
// Main content area
|
||||||
|
Item {
|
||||||
|
id: content
|
||||||
|
anchors.fill: parent
|
||||||
|
anchors.margins: 10
|
||||||
|
|
||||||
|
Loader {
|
||||||
|
id: viewLoader
|
||||||
|
anchors.fill: parent
|
||||||
|
sourceComponent: settingsContainer.layoutMode === "list" ? listComp : gridComp
|
||||||
|
}
|
||||||
|
|
||||||
|
Component {
|
||||||
|
id: gridComp
|
||||||
|
GridView {
|
||||||
|
id: gridView
|
||||||
|
anchors.fill: parent
|
||||||
|
cellWidth: Math.max(64, width / Math.max(1, settingsContainer.layoutColumns))
|
||||||
|
cellHeight: cellWidth
|
||||||
|
model: buttonsModel
|
||||||
|
clip: true
|
||||||
|
interactive: true
|
||||||
|
delegate: ButtonDelegate {
|
||||||
|
width: gridView.cellWidth - settingsContainer.layoutSpacing
|
||||||
|
height: gridView.cellHeight - settingsContainer.layoutSpacing
|
||||||
|
showLabel: settingsContainer.showLabels
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Component {
|
||||||
|
id: listComp
|
||||||
|
SwipeView {
|
||||||
|
id: swipeView
|
||||||
|
anchors.fill: parent
|
||||||
|
clip: true
|
||||||
|
Repeater {
|
||||||
|
model: buttonsModel
|
||||||
|
Item {
|
||||||
|
ButtonDelegate {
|
||||||
|
anchors.centerIn: parent
|
||||||
|
width: Math.min(swipeView.width, swipeView.height) * 0.8
|
||||||
|
height: width
|
||||||
|
showLabel: settingsContainer.showLabels
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Text {
|
||||||
|
anchors.centerIn: parent
|
||||||
|
visible: buttonsModel.count === 0
|
||||||
|
text: "Нет кнопок.\nОткройте настройки и добавьте кнопку."
|
||||||
|
color: root.darkMode ? "#cccccc" : "#222222"
|
||||||
|
font.pixelSize: 18
|
||||||
|
horizontalAlignment: Text.AlignHCenter
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Settings gear (top-right)
|
||||||
|
Rectangle {
|
||||||
|
id: settingsBtn
|
||||||
|
anchors.right: parent.right
|
||||||
|
anchors.top: parent.top
|
||||||
|
anchors.margins: 8
|
||||||
|
width: Math.max(40, parent.width / 14)
|
||||||
|
height: width
|
||||||
|
color: "transparent"
|
||||||
|
opacity: 0.85
|
||||||
|
|
||||||
|
Rectangle {
|
||||||
|
anchors.fill: parent
|
||||||
|
radius: width / 2
|
||||||
|
color: root.darkMode ? "#40ffffff" : "#40000000"
|
||||||
|
}
|
||||||
|
|
||||||
|
Text {
|
||||||
|
anchors.centerIn: parent
|
||||||
|
text: "\u2699"
|
||||||
|
font.pixelSize: parent.width * 0.6
|
||||||
|
color: root.darkMode ? "white" : "black"
|
||||||
|
}
|
||||||
|
|
||||||
|
MouseArea {
|
||||||
|
anchors.fill: parent
|
||||||
|
onClicked: passwordDialog.open()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Password dialog
|
||||||
|
Dialog {
|
||||||
|
id: passwordDialog
|
||||||
|
modal: true
|
||||||
|
title: "Пароль"
|
||||||
|
anchors.centerIn: parent
|
||||||
|
width: Math.min(420, parent.width * 0.8)
|
||||||
|
closePolicy: Popup.CloseOnEscape
|
||||||
|
standardButtons: Dialog.Ok | Dialog.Cancel
|
||||||
|
|
||||||
|
contentItem: ColumnLayout {
|
||||||
|
spacing: 8
|
||||||
|
TextField {
|
||||||
|
id: pwdField
|
||||||
|
Layout.fillWidth: true
|
||||||
|
placeholderText: "Введите пароль"
|
||||||
|
echoMode: TextInput.Password
|
||||||
|
onAccepted: passwordDialog.accept()
|
||||||
|
}
|
||||||
|
Text {
|
||||||
|
id: wrongPwd
|
||||||
|
Layout.fillWidth: true
|
||||||
|
visible: false
|
||||||
|
color: "red"
|
||||||
|
text: "Неверный пароль"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onOpened: {
|
||||||
|
pwdField.text = ""
|
||||||
|
wrongPwd.visible = false
|
||||||
|
pwdField.forceActiveFocus()
|
||||||
|
}
|
||||||
|
onAccepted: {
|
||||||
|
if (btnController.checkPassword(pwdField.text)) {
|
||||||
|
wrongPwd.visible = false
|
||||||
|
pwdField.text = ""
|
||||||
|
settingsDialog.open()
|
||||||
|
} else {
|
||||||
|
wrongPwd.visible = true
|
||||||
|
Qt.callLater(passwordDialog.open)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Settings dialog (single instance)
|
||||||
|
SettingsDialog {
|
||||||
|
id: settingsDialog
|
||||||
|
}
|
||||||
|
|
||||||
|
// Virtual keyboard — parented to Overlay so it's above dialogs
|
||||||
|
InputPanel {
|
||||||
|
id: inputPanel
|
||||||
|
parent: Overlay.overlay
|
||||||
|
z: 999
|
||||||
|
anchors.left: parent.left
|
||||||
|
anchors.right: parent.right
|
||||||
|
anchors.bottom: parent.bottom
|
||||||
|
visible: active
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
import QtQuick 2.12
|
||||||
|
import QtQuick.Controls 2.12
|
||||||
|
import QtQuick.Layouts 1.12
|
||||||
|
import "settings"
|
||||||
|
|
||||||
|
Dialog {
|
||||||
|
id: dlg
|
||||||
|
modal: true
|
||||||
|
title: "Настройки"
|
||||||
|
width: Math.min(720, parent ? parent.width * 0.95 : 720)
|
||||||
|
height: Math.min(560, parent ? parent.height * 0.95 : 560)
|
||||||
|
anchors.centerIn: parent
|
||||||
|
standardButtons: Dialog.Close
|
||||||
|
|
||||||
|
StackView {
|
||||||
|
id: stack
|
||||||
|
anchors.fill: parent
|
||||||
|
initialItem: mainPageComp
|
||||||
|
}
|
||||||
|
|
||||||
|
Component {
|
||||||
|
id: mainPageComp
|
||||||
|
MainPage {
|
||||||
|
onOpenButtons: stack.push(buttonsPageComp)
|
||||||
|
onOpenLayout: stack.push(layoutPageComp)
|
||||||
|
onOpenBackground: stack.push(backgroundPageComp)
|
||||||
|
onOpenFeedback: stack.push(feedbackPageComp)
|
||||||
|
onOpenNetwork: stack.push(networkPageComp)
|
||||||
|
onOpenUpdate: stack.push(updatePageComp)
|
||||||
|
onChangePassword: changePwdDialog.open()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Component {
|
||||||
|
id: buttonsPageComp
|
||||||
|
ButtonsPage {
|
||||||
|
onCreateRequested: editorDialog.openForCreate()
|
||||||
|
onEditRequested: function(data) { editorDialog.openForEdit(data) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Component { id: layoutPageComp; LayoutPage {} }
|
||||||
|
Component { id: backgroundPageComp; BackgroundPage {} }
|
||||||
|
Component { id: feedbackPageComp; FeedbackPage {} }
|
||||||
|
Component { id: networkPageComp; NetworkInfoPage {} }
|
||||||
|
Component { id: updatePageComp; UpdatePage {} }
|
||||||
|
|
||||||
|
ButtonEditorDialog { id: editorDialog }
|
||||||
|
|
||||||
|
Dialog {
|
||||||
|
id: changePwdDialog
|
||||||
|
modal: true
|
||||||
|
title: "Сменить пароль"
|
||||||
|
anchors.centerIn: parent
|
||||||
|
standardButtons: Dialog.Ok | Dialog.Cancel
|
||||||
|
TextField {
|
||||||
|
id: newPwdField
|
||||||
|
anchors.fill: parent
|
||||||
|
placeholderText: "Новый пароль"
|
||||||
|
echoMode: TextInput.Password
|
||||||
|
}
|
||||||
|
onAccepted: {
|
||||||
|
if (newPwdField.text.length > 0)
|
||||||
|
btnController.setPassword(newPwdField.text)
|
||||||
|
newPwdField.text = ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
import QtQuick 2.12
|
||||||
|
import QtQuick.Controls 2.12
|
||||||
|
import QtQuick.Layouts 1.12
|
||||||
|
|
||||||
|
RowLayout {
|
||||||
|
id: header
|
||||||
|
property string title: ""
|
||||||
|
signal back()
|
||||||
|
|
||||||
|
Layout.fillWidth: true
|
||||||
|
spacing: 8
|
||||||
|
|
||||||
|
Button {
|
||||||
|
text: "\u2190 Назад"
|
||||||
|
onClicked: header.back()
|
||||||
|
}
|
||||||
|
Label {
|
||||||
|
Layout.fillWidth: true
|
||||||
|
text: header.title
|
||||||
|
font.bold: true
|
||||||
|
font.pixelSize: 16
|
||||||
|
color: settingsContainer.darkMode ? "white" : "black"
|
||||||
|
horizontalAlignment: Text.AlignHCenter
|
||||||
|
elide: Text.ElideRight
|
||||||
|
}
|
||||||
|
Item { Layout.preferredWidth: 88 }
|
||||||
|
}
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
import QtQuick 2.12
|
||||||
|
import QtQuick.Controls 2.12
|
||||||
|
import QtQuick.Layouts 1.12
|
||||||
|
|
||||||
|
RowLayout {
|
||||||
|
id: control
|
||||||
|
property string label: ""
|
||||||
|
property real from: 0
|
||||||
|
property real to: 100
|
||||||
|
property real stepSize: 1
|
||||||
|
property real value: 0
|
||||||
|
signal moved(real value)
|
||||||
|
|
||||||
|
Layout.fillWidth: true
|
||||||
|
spacing: 10
|
||||||
|
|
||||||
|
Label {
|
||||||
|
text: control.label
|
||||||
|
color: settingsContainer.darkMode ? "white" : "black"
|
||||||
|
Layout.preferredWidth: 150
|
||||||
|
}
|
||||||
|
Slider {
|
||||||
|
id: slider
|
||||||
|
Layout.fillWidth: true
|
||||||
|
from: control.from
|
||||||
|
to: control.to
|
||||||
|
stepSize: control.stepSize
|
||||||
|
value: control.value
|
||||||
|
onMoved: control.moved(Math.round(value))
|
||||||
|
}
|
||||||
|
Label {
|
||||||
|
text: Math.round(slider.value)
|
||||||
|
color: settingsContainer.darkMode ? "white" : "black"
|
||||||
|
Layout.preferredWidth: 36
|
||||||
|
horizontalAlignment: Text.AlignRight
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,108 @@
|
|||||||
|
import QtQuick 2.12
|
||||||
|
import QtQuick.Controls 2.12
|
||||||
|
import QtQuick.Layouts 1.12
|
||||||
|
import "../components"
|
||||||
|
|
||||||
|
Item {
|
||||||
|
id: page
|
||||||
|
|
||||||
|
ColumnLayout {
|
||||||
|
anchors.fill: parent
|
||||||
|
anchors.margins: 8
|
||||||
|
spacing: 12
|
||||||
|
|
||||||
|
BackHeader { title: "Фон"; onBack: page.StackView.view.pop() }
|
||||||
|
|
||||||
|
RowLayout {
|
||||||
|
Layout.fillWidth: true
|
||||||
|
Text {
|
||||||
|
text: "Тип:"
|
||||||
|
color: settingsContainer.darkMode ? "white" : "black"
|
||||||
|
}
|
||||||
|
Item { Layout.fillWidth: true }
|
||||||
|
Button {
|
||||||
|
text: "Градиент"
|
||||||
|
highlighted: settingsContainer.backgroundType === "gradient"
|
||||||
|
onClicked: btnController.setBackgroundType("gradient")
|
||||||
|
}
|
||||||
|
Button {
|
||||||
|
text: "Цвет"
|
||||||
|
highlighted: settingsContainer.backgroundType === "solid"
|
||||||
|
onClicked: btnController.setBackgroundType("solid")
|
||||||
|
}
|
||||||
|
Button {
|
||||||
|
text: "Картинка"
|
||||||
|
highlighted: settingsContainer.backgroundType === "image"
|
||||||
|
onClicked: btnController.setBackgroundType("image")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
GridLayout {
|
||||||
|
Layout.fillWidth: true
|
||||||
|
columns: 2
|
||||||
|
visible: settingsContainer.backgroundType !== "image"
|
||||||
|
|
||||||
|
Text { text: "Цвет 1:"; color: settingsContainer.darkMode ? "white" : "black" }
|
||||||
|
TextField {
|
||||||
|
id: c1
|
||||||
|
Layout.fillWidth: true
|
||||||
|
text: settingsContainer.backgroundColor1
|
||||||
|
onEditingFinished: btnController.setBackgroundColors(text, c2.text)
|
||||||
|
}
|
||||||
|
Text {
|
||||||
|
text: "Цвет 2:"
|
||||||
|
color: settingsContainer.darkMode ? "white" : "black"
|
||||||
|
visible: settingsContainer.backgroundType === "gradient"
|
||||||
|
}
|
||||||
|
TextField {
|
||||||
|
id: c2
|
||||||
|
Layout.fillWidth: true
|
||||||
|
text: settingsContainer.backgroundColor2
|
||||||
|
visible: settingsContainer.backgroundType === "gradient"
|
||||||
|
onEditingFinished: btnController.setBackgroundColors(c1.text, text)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
CheckBox {
|
||||||
|
visible: settingsContainer.backgroundType === "gradient"
|
||||||
|
text: "Анимация"
|
||||||
|
checked: settingsContainer.backgroundAnimated
|
||||||
|
onToggled: btnController.setBackgroundAnimated(checked)
|
||||||
|
}
|
||||||
|
|
||||||
|
ColumnLayout {
|
||||||
|
visible: settingsContainer.backgroundType === "image"
|
||||||
|
Layout.fillWidth: true
|
||||||
|
Text {
|
||||||
|
text: "Имя файла фона (из папки иконок):"
|
||||||
|
color: settingsContainer.darkMode ? "white" : "black"
|
||||||
|
}
|
||||||
|
RowLayout {
|
||||||
|
Layout.fillWidth: true
|
||||||
|
ComboBox {
|
||||||
|
id: bgImageCombo
|
||||||
|
Layout.fillWidth: true
|
||||||
|
model: btnController.listIcons()
|
||||||
|
Component.onCompleted: {
|
||||||
|
var i = model.indexOf(settingsContainer.backgroundImage)
|
||||||
|
if (i >= 0) currentIndex = i
|
||||||
|
}
|
||||||
|
onActivated: btnController.setBackgroundImage(currentText)
|
||||||
|
}
|
||||||
|
Button {
|
||||||
|
text: "\u21BB"
|
||||||
|
onClicked: bgImageCombo.model = btnController.listIcons()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Text {
|
||||||
|
Layout.fillWidth: true
|
||||||
|
text: "Папка иконок:\n" + settingsContainer.iconsDir
|
||||||
|
color: settingsContainer.darkMode ? "#cccccc" : "#444444"
|
||||||
|
font.pixelSize: 11
|
||||||
|
wrapMode: Text.Wrap
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Item { Layout.fillHeight: true }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,116 @@
|
|||||||
|
import QtQuick 2.12
|
||||||
|
import QtQuick.Controls 2.12
|
||||||
|
import QtQuick.Layouts 1.12
|
||||||
|
|
||||||
|
Dialog {
|
||||||
|
id: editorDialog
|
||||||
|
modal: true
|
||||||
|
title: editId === "" ? "Новая кнопка" : "Редактирование"
|
||||||
|
anchors.centerIn: parent
|
||||||
|
standardButtons: Dialog.Ok | Dialog.Cancel
|
||||||
|
|
||||||
|
property string editId: ""
|
||||||
|
|
||||||
|
function openForCreate() {
|
||||||
|
editId = ""
|
||||||
|
labelF.text = ""
|
||||||
|
urlF.text = ""
|
||||||
|
successF.text = ""
|
||||||
|
errorF.text = "Ошибка"
|
||||||
|
colorF.text = "#7b007b"
|
||||||
|
iconCombo.model = btnController.listIcons()
|
||||||
|
iconCombo.currentIndex = 0
|
||||||
|
typeCombo.currentIndex = 0
|
||||||
|
open()
|
||||||
|
}
|
||||||
|
|
||||||
|
function openForEdit(o) {
|
||||||
|
editId = o.btnId
|
||||||
|
labelF.text = o.label
|
||||||
|
urlF.text = o.actionUrl
|
||||||
|
successF.text = o.successText
|
||||||
|
errorF.text = o.errorText
|
||||||
|
colorF.text = o.btnColor || "#7b007b"
|
||||||
|
iconCombo.model = btnController.listIcons()
|
||||||
|
var ip = o.iconPath || ""
|
||||||
|
var fname = ip
|
||||||
|
var slash = ip.lastIndexOf("/")
|
||||||
|
if (slash >= 0) fname = ip.substring(slash + 1)
|
||||||
|
var idx = iconCombo.model.indexOf(fname)
|
||||||
|
iconCombo.currentIndex = idx >= 0 ? idx : 0
|
||||||
|
typeCombo.currentIndex = (o.actionType === "http_post") ? 1 : 0
|
||||||
|
open()
|
||||||
|
}
|
||||||
|
|
||||||
|
ColumnLayout {
|
||||||
|
anchors.fill: parent
|
||||||
|
spacing: 6
|
||||||
|
TextField {
|
||||||
|
id: labelF
|
||||||
|
Layout.fillWidth: true
|
||||||
|
placeholderText: "Подпись"
|
||||||
|
}
|
||||||
|
RowLayout {
|
||||||
|
Layout.fillWidth: true
|
||||||
|
Text { text: "Иконка:"; color: settingsContainer.darkMode ? "white" : "black" }
|
||||||
|
ComboBox {
|
||||||
|
id: iconCombo
|
||||||
|
Layout.fillWidth: true
|
||||||
|
model: btnController.listIcons()
|
||||||
|
}
|
||||||
|
Button {
|
||||||
|
text: "\u21BB"
|
||||||
|
onClicked: iconCombo.model = btnController.listIcons()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
RowLayout {
|
||||||
|
Layout.fillWidth: true
|
||||||
|
Text { text: "Метод:"; color: settingsContainer.darkMode ? "white" : "black" }
|
||||||
|
ComboBox {
|
||||||
|
id: typeCombo
|
||||||
|
model: ["http_get", "http_post"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
TextField {
|
||||||
|
id: urlF
|
||||||
|
Layout.fillWidth: true
|
||||||
|
placeholderText: "URL"
|
||||||
|
}
|
||||||
|
TextField {
|
||||||
|
id: successF
|
||||||
|
Layout.fillWidth: true
|
||||||
|
placeholderText: "Текст успеха"
|
||||||
|
}
|
||||||
|
TextField {
|
||||||
|
id: errorF
|
||||||
|
Layout.fillWidth: true
|
||||||
|
placeholderText: "Текст ошибки"
|
||||||
|
}
|
||||||
|
RowLayout {
|
||||||
|
Layout.fillWidth: true
|
||||||
|
Text { text: "Цвет:"; color: settingsContainer.darkMode ? "white" : "black" }
|
||||||
|
TextField {
|
||||||
|
id: colorF
|
||||||
|
Layout.fillWidth: true
|
||||||
|
placeholderText: "#7b007b"
|
||||||
|
}
|
||||||
|
Rectangle {
|
||||||
|
width: 28; height: 28; radius: 14
|
||||||
|
color: colorF.text || "#7b007b"
|
||||||
|
border.color: "#80ffffff"; border.width: 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onAccepted: {
|
||||||
|
if (editId === "") {
|
||||||
|
btnController.addButton(labelF.text, iconCombo.currentText,
|
||||||
|
typeCombo.currentText, urlF.text,
|
||||||
|
successF.text, errorF.text, colorF.text)
|
||||||
|
} else {
|
||||||
|
btnController.updateButton(editId, labelF.text, iconCombo.currentText,
|
||||||
|
typeCombo.currentText, urlF.text,
|
||||||
|
successF.text, errorF.text, colorF.text)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,141 @@
|
|||||||
|
import QtQuick 2.12
|
||||||
|
import QtQuick.Controls 2.12
|
||||||
|
import QtQuick.Layouts 1.12
|
||||||
|
import "../components"
|
||||||
|
|
||||||
|
Item {
|
||||||
|
id: page
|
||||||
|
|
||||||
|
signal createRequested()
|
||||||
|
signal editRequested(var data)
|
||||||
|
|
||||||
|
ColumnLayout {
|
||||||
|
anchors.fill: parent
|
||||||
|
anchors.margins: 8
|
||||||
|
spacing: 8
|
||||||
|
|
||||||
|
RowLayout {
|
||||||
|
Layout.fillWidth: true
|
||||||
|
Button { text: "\u2190 Назад"; onClicked: page.StackView.view.pop() }
|
||||||
|
Label {
|
||||||
|
Layout.fillWidth: true
|
||||||
|
text: "Кнопки"
|
||||||
|
font.bold: true
|
||||||
|
font.pixelSize: 16
|
||||||
|
color: settingsContainer.darkMode ? "white" : "black"
|
||||||
|
horizontalAlignment: Text.AlignHCenter
|
||||||
|
}
|
||||||
|
Button {
|
||||||
|
text: "+ Добавить"
|
||||||
|
onClicked: page.createRequested()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Text {
|
||||||
|
Layout.fillWidth: true
|
||||||
|
text: "Удерживайте «≡» и перетащите для переупорядочивания"
|
||||||
|
color: settingsContainer.darkMode ? "#cccccc" : "#444444"
|
||||||
|
font.pixelSize: 11
|
||||||
|
horizontalAlignment: Text.AlignHCenter
|
||||||
|
}
|
||||||
|
|
||||||
|
ListView {
|
||||||
|
id: btnList
|
||||||
|
Layout.fillWidth: true
|
||||||
|
Layout.fillHeight: true
|
||||||
|
clip: true
|
||||||
|
spacing: 4
|
||||||
|
model: buttonsModel
|
||||||
|
|
||||||
|
delegate: Item {
|
||||||
|
id: row
|
||||||
|
width: btnList.width
|
||||||
|
height: 60
|
||||||
|
|
||||||
|
Rectangle {
|
||||||
|
anchors.fill: parent
|
||||||
|
radius: 6
|
||||||
|
color: ma.drag.active
|
||||||
|
? "#80ffd966"
|
||||||
|
: (settingsContainer.darkMode ? "#40222c58" : "#40ffffff")
|
||||||
|
border.color: settingsContainer.darkMode ? "#80ffffff" : "#80000000"
|
||||||
|
border.width: 1
|
||||||
|
|
||||||
|
RowLayout {
|
||||||
|
anchors.fill: parent
|
||||||
|
anchors.margins: 6
|
||||||
|
spacing: 8
|
||||||
|
|
||||||
|
Image {
|
||||||
|
Layout.preferredWidth: 40
|
||||||
|
Layout.preferredHeight: 40
|
||||||
|
fillMode: Image.PreserveAspectFit
|
||||||
|
source: iconPath
|
||||||
|
}
|
||||||
|
ColumnLayout {
|
||||||
|
Layout.fillWidth: true
|
||||||
|
Text {
|
||||||
|
Layout.fillWidth: true
|
||||||
|
text: label
|
||||||
|
color: settingsContainer.darkMode ? "white" : "black"
|
||||||
|
font.bold: true
|
||||||
|
elide: Text.ElideRight
|
||||||
|
}
|
||||||
|
Text {
|
||||||
|
Layout.fillWidth: true
|
||||||
|
text: actionType + ": " + actionUrl
|
||||||
|
color: settingsContainer.darkMode ? "#cccccc" : "#444444"
|
||||||
|
font.pixelSize: 11
|
||||||
|
elide: Text.ElideRight
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Rectangle {
|
||||||
|
Layout.preferredWidth: 18
|
||||||
|
Layout.preferredHeight: 18
|
||||||
|
radius: 9
|
||||||
|
color: btnColor || "#7b007b"
|
||||||
|
border.color: "#80ffffff"; border.width: 1
|
||||||
|
}
|
||||||
|
Button {
|
||||||
|
text: "\u270E"
|
||||||
|
onClicked: page.editRequested(buttonsModel.get(index))
|
||||||
|
}
|
||||||
|
Button {
|
||||||
|
text: "\u2715"
|
||||||
|
onClicked: btnController.removeButton(btnId)
|
||||||
|
}
|
||||||
|
Text {
|
||||||
|
text: "\u2261"
|
||||||
|
font.pixelSize: 22
|
||||||
|
color: settingsContainer.darkMode ? "#cccccc" : "#666666"
|
||||||
|
Layout.preferredWidth: 24
|
||||||
|
horizontalAlignment: Text.AlignHCenter
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
MouseArea {
|
||||||
|
id: ma
|
||||||
|
anchors.right: parent.right
|
||||||
|
width: 30
|
||||||
|
height: parent.height
|
||||||
|
drag.target: row
|
||||||
|
drag.axis: Drag.YAxis
|
||||||
|
drag.minimumY: -row.height
|
||||||
|
drag.maximumY: btnList.height
|
||||||
|
|
||||||
|
onPressed: row.z = 2
|
||||||
|
onReleased: {
|
||||||
|
row.z = 0
|
||||||
|
var dy = row.y - index * (row.height + btnList.spacing)
|
||||||
|
var delta = Math.round(dy / (row.height + btnList.spacing))
|
||||||
|
var target = Math.max(0, Math.min(buttonsModel.count - 1, index + delta))
|
||||||
|
row.y = index * (row.height + btnList.spacing)
|
||||||
|
if (target !== index)
|
||||||
|
btnController.moveButton(index, target)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,116 @@
|
|||||||
|
import QtQuick 2.12
|
||||||
|
import QtQuick.Controls 2.12
|
||||||
|
import QtQuick.Layouts 1.12
|
||||||
|
import QtGraphicalEffects 1.12
|
||||||
|
import "../components"
|
||||||
|
|
||||||
|
Item {
|
||||||
|
id: page
|
||||||
|
|
||||||
|
ColumnLayout {
|
||||||
|
anchors.fill: parent
|
||||||
|
anchors.margins: 8
|
||||||
|
spacing: 12
|
||||||
|
|
||||||
|
BackHeader { title: "Обводка отклика"; onBack: page.StackView.view.pop() }
|
||||||
|
|
||||||
|
Text {
|
||||||
|
Layout.fillWidth: true
|
||||||
|
text: "Цвет и ширина свечения вокруг кнопки при успехе (ОК) и ошибке."
|
||||||
|
color: settingsContainer.darkMode ? "#cccccc" : "#444444"
|
||||||
|
font.pixelSize: 12
|
||||||
|
wrapMode: Text.Wrap
|
||||||
|
}
|
||||||
|
|
||||||
|
// OK color
|
||||||
|
RowLayout {
|
||||||
|
Layout.fillWidth: true
|
||||||
|
Text { text: "Цвет «ОК»:"; color: settingsContainer.darkMode ? "white" : "black"; Layout.preferredWidth: 150 }
|
||||||
|
TextField {
|
||||||
|
id: okColor
|
||||||
|
Layout.fillWidth: true
|
||||||
|
text: settingsContainer.feedbackOkColor
|
||||||
|
onEditingFinished: btnController.setFeedbackColors(text, errorColor.text)
|
||||||
|
}
|
||||||
|
Rectangle {
|
||||||
|
width: 28; height: 28; radius: 14
|
||||||
|
color: okColor.text || "#00cc44"
|
||||||
|
border.color: "#80ffffff"; border.width: 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
LabeledSlider {
|
||||||
|
label: "Ширина «ОК»"
|
||||||
|
from: 0; to: 30; stepSize: 1
|
||||||
|
value: settingsContainer.feedbackOkWidth
|
||||||
|
onMoved: btnController.setFeedbackWidths(value, settingsContainer.feedbackErrorWidth)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Error color
|
||||||
|
RowLayout {
|
||||||
|
Layout.fillWidth: true
|
||||||
|
Text { text: "Цвет «Ошибка»:"; color: settingsContainer.darkMode ? "white" : "black"; Layout.preferredWidth: 150 }
|
||||||
|
TextField {
|
||||||
|
id: errorColor
|
||||||
|
Layout.fillWidth: true
|
||||||
|
text: settingsContainer.feedbackErrorColor
|
||||||
|
onEditingFinished: btnController.setFeedbackColors(okColor.text, text)
|
||||||
|
}
|
||||||
|
Rectangle {
|
||||||
|
width: 28; height: 28; radius: 14
|
||||||
|
color: errorColor.text || "#cc2200"
|
||||||
|
border.color: "#80ffffff"; border.width: 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
LabeledSlider {
|
||||||
|
label: "Ширина «Ошибка»"
|
||||||
|
from: 0; to: 30; stepSize: 1
|
||||||
|
value: settingsContainer.feedbackErrorWidth
|
||||||
|
onMoved: btnController.setFeedbackWidths(settingsContainer.feedbackOkWidth, value)
|
||||||
|
}
|
||||||
|
|
||||||
|
LabeledSlider {
|
||||||
|
label: "Радиус свечения"
|
||||||
|
from: 0; to: 60; stepSize: 1
|
||||||
|
value: settingsContainer.feedbackGlowRadius
|
||||||
|
onMoved: btnController.setFeedbackGlowRadius(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Preview
|
||||||
|
RowLayout {
|
||||||
|
Layout.fillWidth: true
|
||||||
|
Layout.topMargin: 8
|
||||||
|
spacing: 32
|
||||||
|
Item { Layout.fillWidth: true }
|
||||||
|
Repeater {
|
||||||
|
model: [
|
||||||
|
{ c: settingsContainer.feedbackOkColor, w: settingsContainer.feedbackOkWidth, t: "ОК" },
|
||||||
|
{ c: settingsContainer.feedbackErrorColor, w: settingsContainer.feedbackErrorWidth, t: "Ошибка" }
|
||||||
|
]
|
||||||
|
delegate: Rectangle {
|
||||||
|
width: 84; height: 84; radius: 42
|
||||||
|
color: "#7b007b"
|
||||||
|
border.color: modelData.c
|
||||||
|
border.width: modelData.w
|
||||||
|
Text {
|
||||||
|
anchors.centerIn: parent
|
||||||
|
text: modelData.t
|
||||||
|
color: "white"
|
||||||
|
font.bold: true
|
||||||
|
}
|
||||||
|
layer.enabled: settingsContainer.feedbackGlowRadius > 0
|
||||||
|
layer.effect: Glow {
|
||||||
|
radius: settingsContainer.feedbackGlowRadius
|
||||||
|
samples: 24
|
||||||
|
color: modelData.c
|
||||||
|
transparentBorder: true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Item { Layout.fillWidth: true }
|
||||||
|
}
|
||||||
|
|
||||||
|
Item { Layout.fillHeight: true }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
import QtQuick 2.12
|
||||||
|
import QtQuick.Controls 2.12
|
||||||
|
import QtQuick.Layouts 1.12
|
||||||
|
import "../components"
|
||||||
|
|
||||||
|
Item {
|
||||||
|
id: page
|
||||||
|
|
||||||
|
ColumnLayout {
|
||||||
|
anchors.fill: parent
|
||||||
|
anchors.margins: 8
|
||||||
|
spacing: 12
|
||||||
|
|
||||||
|
BackHeader { title: "Расположение"; onBack: page.StackView.view.pop() }
|
||||||
|
|
||||||
|
RowLayout {
|
||||||
|
Layout.fillWidth: true
|
||||||
|
Text {
|
||||||
|
text: "Режим:"
|
||||||
|
color: settingsContainer.darkMode ? "white" : "black"
|
||||||
|
}
|
||||||
|
Item { Layout.fillWidth: true }
|
||||||
|
Button {
|
||||||
|
text: "Сетка"
|
||||||
|
highlighted: settingsContainer.layoutMode === "grid"
|
||||||
|
onClicked: btnController.setLayoutMode("grid")
|
||||||
|
}
|
||||||
|
Button {
|
||||||
|
text: "Список"
|
||||||
|
highlighted: settingsContainer.layoutMode === "list"
|
||||||
|
onClicked: btnController.setLayoutMode("list")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
LabeledSlider {
|
||||||
|
visible: settingsContainer.layoutMode === "grid"
|
||||||
|
label: "Колонок"
|
||||||
|
from: 1; to: 8; stepSize: 1
|
||||||
|
value: settingsContainer.layoutColumns
|
||||||
|
onMoved: btnController.setLayoutColumns(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
LabeledSlider {
|
||||||
|
label: "Отступ"
|
||||||
|
from: 0; to: 40; stepSize: 1
|
||||||
|
value: settingsContainer.layoutSpacing
|
||||||
|
onMoved: btnController.setLayoutSpacing(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
CheckBox {
|
||||||
|
text: "Показывать подписи"
|
||||||
|
checked: settingsContainer.showLabels
|
||||||
|
onToggled: btnController.setLayoutShowLabels(checked)
|
||||||
|
}
|
||||||
|
|
||||||
|
Item { Layout.fillHeight: true }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
import QtQuick 2.12
|
||||||
|
import QtQuick.Controls 2.12
|
||||||
|
import QtQuick.Layouts 1.12
|
||||||
|
|
||||||
|
Item {
|
||||||
|
id: page
|
||||||
|
|
||||||
|
signal openButtons()
|
||||||
|
signal openLayout()
|
||||||
|
signal openBackground()
|
||||||
|
signal openFeedback()
|
||||||
|
signal openNetwork()
|
||||||
|
signal openUpdate()
|
||||||
|
signal changePassword()
|
||||||
|
|
||||||
|
ColumnLayout {
|
||||||
|
anchors.fill: parent
|
||||||
|
anchors.margins: 8
|
||||||
|
spacing: 8
|
||||||
|
|
||||||
|
Button {
|
||||||
|
Layout.fillWidth: true
|
||||||
|
text: "Кнопки (CRUD + порядок)"
|
||||||
|
onClicked: page.openButtons()
|
||||||
|
}
|
||||||
|
Button {
|
||||||
|
Layout.fillWidth: true
|
||||||
|
text: "Расположение: " + settingsContainer.layoutMode +
|
||||||
|
" (" + (settingsContainer.layoutMode === "grid"
|
||||||
|
? settingsContainer.layoutColumns + " колонок"
|
||||||
|
: "список") + ")"
|
||||||
|
onClicked: page.openLayout()
|
||||||
|
}
|
||||||
|
Button {
|
||||||
|
Layout.fillWidth: true
|
||||||
|
text: "Фон"
|
||||||
|
onClicked: page.openBackground()
|
||||||
|
}
|
||||||
|
Button {
|
||||||
|
Layout.fillWidth: true
|
||||||
|
text: "Обводка отклика (ОК / Ошибка)"
|
||||||
|
onClicked: page.openFeedback()
|
||||||
|
}
|
||||||
|
Button {
|
||||||
|
Layout.fillWidth: true
|
||||||
|
text: "Сеть"
|
||||||
|
onClicked: page.openNetwork()
|
||||||
|
}
|
||||||
|
Button {
|
||||||
|
Layout.fillWidth: true
|
||||||
|
text: "Обновление ПО"
|
||||||
|
onClicked: page.openUpdate()
|
||||||
|
}
|
||||||
|
Button {
|
||||||
|
Layout.fillWidth: true
|
||||||
|
text: "Тема: " + (settingsContainer.darkMode ? "тёмная" : "светлая")
|
||||||
|
onClicked: btnController.setDarkMode(!settingsContainer.darkMode)
|
||||||
|
}
|
||||||
|
Button {
|
||||||
|
Layout.fillWidth: true
|
||||||
|
text: "Сменить пароль"
|
||||||
|
onClicked: page.changePassword()
|
||||||
|
}
|
||||||
|
|
||||||
|
Item { Layout.fillHeight: true }
|
||||||
|
|
||||||
|
Text {
|
||||||
|
Layout.fillWidth: true
|
||||||
|
text: "Веб-конфигуратор: порт " + settingsContainer.webPort
|
||||||
|
color: settingsContainer.darkMode ? "#cccccc" : "#444444"
|
||||||
|
font.pixelSize: 12
|
||||||
|
horizontalAlignment: Text.AlignHCenter
|
||||||
|
}
|
||||||
|
Text {
|
||||||
|
Layout.fillWidth: true
|
||||||
|
text: "Конфиг: " + config.configPath
|
||||||
|
color: settingsContainer.darkMode ? "#cccccc" : "#444444"
|
||||||
|
font.pixelSize: 11
|
||||||
|
elide: Text.ElideMiddle
|
||||||
|
horizontalAlignment: Text.AlignHCenter
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,206 @@
|
|||||||
|
import QtQuick 2.12
|
||||||
|
import QtQuick.Controls 2.12
|
||||||
|
import QtQuick.Layouts 1.12
|
||||||
|
import "../components"
|
||||||
|
|
||||||
|
Item {
|
||||||
|
id: page
|
||||||
|
|
||||||
|
property var devices: []
|
||||||
|
property string resultText: ""
|
||||||
|
property bool resultOk: false
|
||||||
|
|
||||||
|
function reload() {
|
||||||
|
devices = systemInfo.networkInfo()
|
||||||
|
var names = []
|
||||||
|
for (var i = 0; i < devices.length; ++i)
|
||||||
|
names.push(devices[i].device)
|
||||||
|
var prev = ifaceCombo.currentText
|
||||||
|
ifaceCombo.model = names
|
||||||
|
var idx = names.indexOf(prev)
|
||||||
|
if (idx >= 0) ifaceCombo.currentIndex = idx
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyNow() {
|
||||||
|
if (!ifaceCombo.currentText) {
|
||||||
|
page.resultOk = false
|
||||||
|
page.resultText = "Выберите интерфейс"
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var mode = staticSwitch.checked ? "static" : "dhcp"
|
||||||
|
var r
|
||||||
|
if (mode === "static") {
|
||||||
|
r = systemInfo.applyNetwork(ifaceCombo.currentText, "static",
|
||||||
|
addrField.text.trim(),
|
||||||
|
parseInt(prefixField.text) || 24,
|
||||||
|
gwField.text.trim(),
|
||||||
|
dnsField.text.trim())
|
||||||
|
} else {
|
||||||
|
r = systemInfo.applyNetwork(ifaceCombo.currentText, "dhcp")
|
||||||
|
}
|
||||||
|
page.resultOk = r.ok === true
|
||||||
|
page.resultText = r.ok ? "Настройки применены" : ("Ошибка: " + (r.error || "неизвестно"))
|
||||||
|
reloadTimer.start()
|
||||||
|
}
|
||||||
|
|
||||||
|
Component.onCompleted: reload()
|
||||||
|
|
||||||
|
Timer {
|
||||||
|
id: reloadTimer
|
||||||
|
interval: 1500
|
||||||
|
onTriggered: page.reload()
|
||||||
|
}
|
||||||
|
|
||||||
|
ColumnLayout {
|
||||||
|
anchors.fill: parent
|
||||||
|
anchors.margins: 8
|
||||||
|
spacing: 12
|
||||||
|
|
||||||
|
RowLayout {
|
||||||
|
Layout.fillWidth: true
|
||||||
|
Button { text: "\u2190 Назад"; onClicked: page.StackView.view.pop() }
|
||||||
|
Label {
|
||||||
|
Layout.fillWidth: true
|
||||||
|
text: "Сеть (Ethernet)"
|
||||||
|
font.bold: true
|
||||||
|
font.pixelSize: 16
|
||||||
|
color: settingsContainer.darkMode ? "white" : "black"
|
||||||
|
horizontalAlignment: Text.AlignHCenter
|
||||||
|
}
|
||||||
|
Button { text: "\u21BB"; onClicked: page.reload() }
|
||||||
|
}
|
||||||
|
|
||||||
|
// ----- Current state -----
|
||||||
|
Text {
|
||||||
|
Layout.fillWidth: true
|
||||||
|
visible: page.devices.length === 0
|
||||||
|
text: "Ethernet-интерфейсы не найдены (или nmcli недоступен)."
|
||||||
|
color: settingsContainer.darkMode ? "#cccccc" : "#444444"
|
||||||
|
wrapMode: Text.Wrap
|
||||||
|
}
|
||||||
|
|
||||||
|
Repeater {
|
||||||
|
model: page.devices
|
||||||
|
delegate: Rectangle {
|
||||||
|
Layout.fillWidth: true
|
||||||
|
Layout.preferredHeight: col.implicitHeight + 16
|
||||||
|
radius: 6
|
||||||
|
color: settingsContainer.darkMode ? "#40222c58" : "#40ffffff"
|
||||||
|
border.color: settingsContainer.darkMode ? "#80ffffff" : "#80000000"
|
||||||
|
border.width: 1
|
||||||
|
|
||||||
|
ColumnLayout {
|
||||||
|
id: col
|
||||||
|
anchors.fill: parent
|
||||||
|
anchors.margins: 8
|
||||||
|
spacing: 2
|
||||||
|
Text {
|
||||||
|
text: modelData.device + " (" + modelData.state + ")"
|
||||||
|
color: settingsContainer.darkMode ? "white" : "black"
|
||||||
|
font.bold: true
|
||||||
|
}
|
||||||
|
Text {
|
||||||
|
text: "Соединение: " + (modelData.connection || "—")
|
||||||
|
color: settingsContainer.darkMode ? "#cccccc" : "#444444"
|
||||||
|
font.pixelSize: 12
|
||||||
|
}
|
||||||
|
Text {
|
||||||
|
text: "IP: " + (modelData.addresses || "—")
|
||||||
|
color: settingsContainer.darkMode ? "#cccccc" : "#444444"
|
||||||
|
font.pixelSize: 12
|
||||||
|
}
|
||||||
|
Text {
|
||||||
|
text: "Шлюз: " + (modelData.gateway || "—") + " DNS: " + (modelData.dns || "—")
|
||||||
|
color: settingsContainer.darkMode ? "#cccccc" : "#444444"
|
||||||
|
font.pixelSize: 12
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Rectangle { Layout.fillWidth: true; height: 1; color: settingsContainer.darkMode ? "#40ffffff" : "#40000000" }
|
||||||
|
|
||||||
|
// ----- Configuration form -----
|
||||||
|
Text {
|
||||||
|
Layout.fillWidth: true
|
||||||
|
text: "Изменение настроек может разорвать соединение и сменить IP устройства."
|
||||||
|
color: "#e0a020"
|
||||||
|
font.pixelSize: 12
|
||||||
|
wrapMode: Text.Wrap
|
||||||
|
}
|
||||||
|
|
||||||
|
RowLayout {
|
||||||
|
Layout.fillWidth: true
|
||||||
|
Text { text: "Интерфейс:"; color: settingsContainer.darkMode ? "white" : "black"; Layout.preferredWidth: 120 }
|
||||||
|
ComboBox {
|
||||||
|
id: ifaceCombo
|
||||||
|
Layout.fillWidth: true
|
||||||
|
model: []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
RowLayout {
|
||||||
|
Layout.fillWidth: true
|
||||||
|
Text { text: "Статический IP:"; color: settingsContainer.darkMode ? "white" : "black"; Layout.preferredWidth: 120 }
|
||||||
|
Switch {
|
||||||
|
id: staticSwitch
|
||||||
|
checked: false
|
||||||
|
}
|
||||||
|
Text {
|
||||||
|
text: staticSwitch.checked ? "Статический" : "DHCP (авто)"
|
||||||
|
color: settingsContainer.darkMode ? "#cccccc" : "#444444"
|
||||||
|
}
|
||||||
|
Item { Layout.fillWidth: true }
|
||||||
|
}
|
||||||
|
|
||||||
|
GridLayout {
|
||||||
|
Layout.fillWidth: true
|
||||||
|
columns: 2
|
||||||
|
visible: staticSwitch.checked
|
||||||
|
|
||||||
|
Text { text: "IP-адрес:"; color: settingsContainer.darkMode ? "white" : "black" }
|
||||||
|
TextField { id: addrField; Layout.fillWidth: true; placeholderText: "192.168.1.50" }
|
||||||
|
|
||||||
|
Text { text: "Префикс:"; color: settingsContainer.darkMode ? "white" : "black" }
|
||||||
|
TextField { id: prefixField; Layout.fillWidth: true; text: "24"; inputMethodHints: Qt.ImhDigitsOnly }
|
||||||
|
|
||||||
|
Text { text: "Шлюз:"; color: settingsContainer.darkMode ? "white" : "black" }
|
||||||
|
TextField { id: gwField; Layout.fillWidth: true; placeholderText: "192.168.1.1" }
|
||||||
|
|
||||||
|
Text { text: "DNS:"; color: settingsContainer.darkMode ? "white" : "black" }
|
||||||
|
TextField { id: dnsField; Layout.fillWidth: true; placeholderText: "8.8.8.8, 1.1.1.1" }
|
||||||
|
}
|
||||||
|
|
||||||
|
RowLayout {
|
||||||
|
Layout.fillWidth: true
|
||||||
|
Button {
|
||||||
|
text: "Применить"
|
||||||
|
highlighted: true
|
||||||
|
onClicked: confirmDialog.open()
|
||||||
|
}
|
||||||
|
Text {
|
||||||
|
Layout.fillWidth: true
|
||||||
|
text: page.resultText
|
||||||
|
color: page.resultOk ? "#30c050" : "#e05050"
|
||||||
|
wrapMode: Text.Wrap
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Item { Layout.fillHeight: true }
|
||||||
|
}
|
||||||
|
|
||||||
|
Dialog {
|
||||||
|
id: confirmDialog
|
||||||
|
modal: true
|
||||||
|
title: "Применить настройки сети?"
|
||||||
|
anchors.centerIn: parent
|
||||||
|
standardButtons: Dialog.Ok | Dialog.Cancel
|
||||||
|
Text {
|
||||||
|
width: parent ? parent.width : 300
|
||||||
|
text: "Соединение может прерваться. Продолжить?"
|
||||||
|
color: settingsContainer.darkMode ? "white" : "black"
|
||||||
|
wrapMode: Text.Wrap
|
||||||
|
}
|
||||||
|
onAccepted: page.applyNow()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
import QtQuick 2.12
|
||||||
|
import QtQuick.Controls 2.12
|
||||||
|
import QtQuick.Layouts 1.12
|
||||||
|
import "../components"
|
||||||
|
|
||||||
|
Item {
|
||||||
|
id: page
|
||||||
|
|
||||||
|
property var status: ({ state: "idle" })
|
||||||
|
|
||||||
|
function stateText(s) {
|
||||||
|
switch (s) {
|
||||||
|
case "staging": return "Распаковка пакета…"
|
||||||
|
case "switching": return "Переключение версии…"
|
||||||
|
case "health_check": return "Проверка работоспособности…"
|
||||||
|
case "success": return "Обновление успешно применено"
|
||||||
|
case "rolledback": return "Запуск новой версии не удался — выполнен откат"
|
||||||
|
case "rollback_done": return "Откат выполнен"
|
||||||
|
case "error": return "Ошибка обновления"
|
||||||
|
default: return "Готово к обновлению"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function reload() {
|
||||||
|
status = systemInfo.updateStatus()
|
||||||
|
systemInfo.refresh()
|
||||||
|
}
|
||||||
|
|
||||||
|
Component.onCompleted: reload()
|
||||||
|
|
||||||
|
Timer {
|
||||||
|
interval: 2500
|
||||||
|
running: true
|
||||||
|
repeat: true
|
||||||
|
onTriggered: page.reload()
|
||||||
|
}
|
||||||
|
|
||||||
|
ColumnLayout {
|
||||||
|
anchors.fill: parent
|
||||||
|
anchors.margins: 8
|
||||||
|
spacing: 12
|
||||||
|
|
||||||
|
RowLayout {
|
||||||
|
Layout.fillWidth: true
|
||||||
|
Button { text: "\u2190 Назад"; onClicked: page.StackView.view.pop() }
|
||||||
|
Label {
|
||||||
|
Layout.fillWidth: true
|
||||||
|
text: "Обновление ПО"
|
||||||
|
font.bold: true
|
||||||
|
font.pixelSize: 16
|
||||||
|
color: settingsContainer.darkMode ? "white" : "black"
|
||||||
|
horizontalAlignment: Text.AlignHCenter
|
||||||
|
}
|
||||||
|
Button { text: "\u21BB"; onClicked: page.reload() }
|
||||||
|
}
|
||||||
|
|
||||||
|
Text {
|
||||||
|
Layout.fillWidth: true
|
||||||
|
text: "Текущая версия: " + systemInfo.currentVersion
|
||||||
|
color: settingsContainer.darkMode ? "white" : "black"
|
||||||
|
font.pixelSize: 14
|
||||||
|
}
|
||||||
|
|
||||||
|
Rectangle {
|
||||||
|
Layout.fillWidth: true
|
||||||
|
Layout.preferredHeight: stTxt.implicitHeight + 20
|
||||||
|
radius: 6
|
||||||
|
color: settingsContainer.darkMode ? "#40222c58" : "#40ffffff"
|
||||||
|
border.color: settingsContainer.darkMode ? "#80ffffff" : "#80000000"
|
||||||
|
border.width: 1
|
||||||
|
Text {
|
||||||
|
id: stTxt
|
||||||
|
anchors.fill: parent
|
||||||
|
anchors.margins: 10
|
||||||
|
text: "Статус: " + page.stateText(page.status.state)
|
||||||
|
+ (page.status.message ? "\n" + page.status.message : "")
|
||||||
|
color: settingsContainer.darkMode ? "white" : "black"
|
||||||
|
wrapMode: Text.Wrap
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Text {
|
||||||
|
Layout.fillWidth: true
|
||||||
|
text: "Загрузка нового пакета ПО выполняется через веб-конфигуратор. После применения выполняется проверка запуска; при ошибке произойдёт автоматический откат на предыдущую версию."
|
||||||
|
color: settingsContainer.darkMode ? "#cccccc" : "#444444"
|
||||||
|
font.pixelSize: 12
|
||||||
|
wrapMode: Text.Wrap
|
||||||
|
}
|
||||||
|
|
||||||
|
Item { Layout.fillHeight: true }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
<RCC>
|
||||||
|
<qresource prefix="/">
|
||||||
|
</qresource>
|
||||||
|
</RCC>
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
<RCC>
|
||||||
|
<qresource prefix="/qml">
|
||||||
|
<file alias="Main.qml">../qml/Main.qml</file>
|
||||||
|
<file alias="BackgroundLayer.qml">../qml/BackgroundLayer.qml</file>
|
||||||
|
<file alias="ButtonDelegate.qml">../qml/ButtonDelegate.qml</file>
|
||||||
|
<file alias="SettingsDialog.qml">../qml/SettingsDialog.qml</file>
|
||||||
|
<file alias="components/BackHeader.qml">../qml/components/BackHeader.qml</file>
|
||||||
|
<file alias="components/LabeledSlider.qml">../qml/components/LabeledSlider.qml</file>
|
||||||
|
<file alias="settings/MainPage.qml">../qml/settings/MainPage.qml</file>
|
||||||
|
<file alias="settings/ButtonsPage.qml">../qml/settings/ButtonsPage.qml</file>
|
||||||
|
<file alias="settings/LayoutPage.qml">../qml/settings/LayoutPage.qml</file>
|
||||||
|
<file alias="settings/BackgroundPage.qml">../qml/settings/BackgroundPage.qml</file>
|
||||||
|
<file alias="settings/FeedbackPage.qml">../qml/settings/FeedbackPage.qml</file>
|
||||||
|
<file alias="settings/NetworkInfoPage.qml">../qml/settings/NetworkInfoPage.qml</file>
|
||||||
|
<file alias="settings/UpdatePage.qml">../qml/settings/UpdatePage.qml</file>
|
||||||
|
<file alias="settings/ButtonEditorDialog.qml">../qml/settings/ButtonEditorDialog.qml</file>
|
||||||
|
</qresource>
|
||||||
|
</RCC>
|
||||||
@@ -0,0 +1,293 @@
|
|||||||
|
#include "buttoncontroller.h"
|
||||||
|
#include "configmanager.h"
|
||||||
|
#include "buttonsmodel.h"
|
||||||
|
|
||||||
|
#include <QNetworkRequest>
|
||||||
|
#include <QNetworkReply>
|
||||||
|
#include <QJsonDocument>
|
||||||
|
#include <QJsonObject>
|
||||||
|
#include <QJsonArray>
|
||||||
|
#include <QTimer>
|
||||||
|
#include <QUrl>
|
||||||
|
#include <QDir>
|
||||||
|
#include <QFileInfo>
|
||||||
|
#include <QPointer>
|
||||||
|
#include <QDebug>
|
||||||
|
|
||||||
|
ButtonController::ButtonController(ConfigManager *config, ButtonsModel *model, QObject *parent)
|
||||||
|
: QObject(parent)
|
||||||
|
, m_config(config)
|
||||||
|
, m_model(model)
|
||||||
|
, m_nam(new QNetworkAccessManager(this))
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
void ButtonController::invokeButton(const QString &id)
|
||||||
|
{
|
||||||
|
QJsonArray arr = m_config->buttons();
|
||||||
|
QJsonObject btn;
|
||||||
|
bool found = false;
|
||||||
|
for (const auto &v : arr) {
|
||||||
|
QJsonObject b = v.toObject();
|
||||||
|
if (b.value("id").toString() == id) {
|
||||||
|
btn = b;
|
||||||
|
found = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!found) {
|
||||||
|
qWarning() << "invokeButton: id not found" << 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("Ошибка"));
|
||||||
|
|
||||||
|
if (url.isEmpty()) {
|
||||||
|
qWarning() << "invokeButton: empty url for" << id;
|
||||||
|
m_model->setStatus(id, 2, errorText);
|
||||||
|
emit buttonInvoked(id, false, errorText);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
m_model->setStatus(id, 3, QStringLiteral("..."));
|
||||||
|
|
||||||
|
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, successText, errorText, fadeMs, acceptAnyResponse]() {
|
||||||
|
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;
|
||||||
|
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
|
||||||
|
{
|
||||||
|
QString stored = m_config->settingsObj().value("password").toString("admin");
|
||||||
|
return pwd == stored;
|
||||||
|
}
|
||||||
|
|
||||||
|
void ButtonController::addButton(const QString &label,
|
||||||
|
const QString &iconPath,
|
||||||
|
const QString &actionType,
|
||||||
|
const QString &actionUrl,
|
||||||
|
const QString &successText,
|
||||||
|
const QString &errorText,
|
||||||
|
const QString &color)
|
||||||
|
{
|
||||||
|
QJsonObject action;
|
||||||
|
action.insert("type", actionType.isEmpty() ? "http_get" : actionType);
|
||||||
|
action.insert("url", actionUrl);
|
||||||
|
action.insert("headers", QJsonObject());
|
||||||
|
action.insert("body", "");
|
||||||
|
|
||||||
|
QJsonObject feedback;
|
||||||
|
feedback.insert("successText", successText);
|
||||||
|
feedback.insert("errorText", errorText);
|
||||||
|
feedback.insert("fadeMs", 5000);
|
||||||
|
|
||||||
|
QJsonObject btn;
|
||||||
|
btn.insert("label", label);
|
||||||
|
btn.insert("iconPath", iconPath);
|
||||||
|
btn.insert("color", color.isEmpty() ? "#7b007b" : color);
|
||||||
|
btn.insert("action", action);
|
||||||
|
btn.insert("feedback", feedback);
|
||||||
|
m_config->addButton(btn);
|
||||||
|
}
|
||||||
|
|
||||||
|
void ButtonController::updateButton(const QString &id,
|
||||||
|
const QString &label,
|
||||||
|
const QString &iconPath,
|
||||||
|
const QString &actionType,
|
||||||
|
const QString &actionUrl,
|
||||||
|
const QString &successText,
|
||||||
|
const QString &errorText,
|
||||||
|
const QString &color)
|
||||||
|
{
|
||||||
|
QJsonArray arr = m_config->buttons();
|
||||||
|
for (const auto &v : arr) {
|
||||||
|
QJsonObject b = v.toObject();
|
||||||
|
if (b.value("id").toString() != id) continue;
|
||||||
|
QJsonObject action = b.value("action").toObject();
|
||||||
|
action.insert("type", actionType.isEmpty() ? "http_get" : actionType);
|
||||||
|
action.insert("url", actionUrl);
|
||||||
|
QJsonObject feedback = b.value("feedback").toObject();
|
||||||
|
feedback.insert("successText", successText);
|
||||||
|
feedback.insert("errorText", errorText);
|
||||||
|
if (!feedback.contains("fadeMs")) feedback.insert("fadeMs", 5000);
|
||||||
|
b.insert("label", label);
|
||||||
|
b.insert("iconPath", iconPath);
|
||||||
|
b.insert("color", color.isEmpty() ? "#7b007b" : color);
|
||||||
|
b.insert("action", action);
|
||||||
|
b.insert("feedback", feedback);
|
||||||
|
m_config->updateButton(id, b);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void ButtonController::removeButton(const QString &id)
|
||||||
|
{
|
||||||
|
m_config->removeButton(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
void ButtonController::moveButton(int from, int to)
|
||||||
|
{
|
||||||
|
m_config->moveButton(from, to);
|
||||||
|
}
|
||||||
|
|
||||||
|
void ButtonController::setLayoutMode(const QString &mode)
|
||||||
|
{
|
||||||
|
QJsonObject l = m_config->layoutObj();
|
||||||
|
l.insert("mode", mode);
|
||||||
|
m_config->setLayout(l);
|
||||||
|
}
|
||||||
|
|
||||||
|
void ButtonController::setLayoutColumns(int columns)
|
||||||
|
{
|
||||||
|
QJsonObject l = m_config->layoutObj();
|
||||||
|
l.insert("columns", columns);
|
||||||
|
m_config->setLayout(l);
|
||||||
|
}
|
||||||
|
|
||||||
|
void ButtonController::setLayoutSpacing(int spacing)
|
||||||
|
{
|
||||||
|
QJsonObject l = m_config->layoutObj();
|
||||||
|
l.insert("spacing", spacing);
|
||||||
|
m_config->setLayout(l);
|
||||||
|
}
|
||||||
|
|
||||||
|
void ButtonController::setLayoutShowLabels(bool show)
|
||||||
|
{
|
||||||
|
QJsonObject l = m_config->layoutObj();
|
||||||
|
l.insert("showLabels", show);
|
||||||
|
m_config->setLayout(l);
|
||||||
|
}
|
||||||
|
|
||||||
|
void ButtonController::setBackgroundType(const QString &type)
|
||||||
|
{
|
||||||
|
QJsonObject b = m_config->backgroundObj();
|
||||||
|
b.insert("type", type);
|
||||||
|
m_config->setBackground(b);
|
||||||
|
}
|
||||||
|
|
||||||
|
void ButtonController::setBackgroundColors(const QString &c1, const QString &c2)
|
||||||
|
{
|
||||||
|
QJsonObject b = m_config->backgroundObj();
|
||||||
|
b.insert("color1", c1);
|
||||||
|
b.insert("color2", c2);
|
||||||
|
m_config->setBackground(b);
|
||||||
|
}
|
||||||
|
|
||||||
|
void ButtonController::setBackgroundAnimated(bool animated)
|
||||||
|
{
|
||||||
|
QJsonObject b = m_config->backgroundObj();
|
||||||
|
b.insert("animated", animated);
|
||||||
|
m_config->setBackground(b);
|
||||||
|
}
|
||||||
|
|
||||||
|
void ButtonController::setBackgroundImage(const QString &path)
|
||||||
|
{
|
||||||
|
QJsonObject b = m_config->backgroundObj();
|
||||||
|
b.insert("imagePath", path);
|
||||||
|
m_config->setBackground(b);
|
||||||
|
}
|
||||||
|
|
||||||
|
void ButtonController::setFeedbackColors(const QString &okColor, const QString &errorColor)
|
||||||
|
{
|
||||||
|
QJsonObject f = m_config->feedbackObj();
|
||||||
|
f.insert("okColor", okColor);
|
||||||
|
f.insert("errorColor", errorColor);
|
||||||
|
m_config->setFeedback(f);
|
||||||
|
}
|
||||||
|
|
||||||
|
void ButtonController::setFeedbackWidths(int okWidth, int errorWidth)
|
||||||
|
{
|
||||||
|
QJsonObject f = m_config->feedbackObj();
|
||||||
|
f.insert("okWidth", okWidth);
|
||||||
|
f.insert("errorWidth", errorWidth);
|
||||||
|
m_config->setFeedback(f);
|
||||||
|
}
|
||||||
|
|
||||||
|
void ButtonController::setFeedbackGlowRadius(int radius)
|
||||||
|
{
|
||||||
|
QJsonObject f = m_config->feedbackObj();
|
||||||
|
f.insert("glowRadius", radius);
|
||||||
|
m_config->setFeedback(f);
|
||||||
|
}
|
||||||
|
|
||||||
|
void ButtonController::setDarkMode(bool dark)
|
||||||
|
{
|
||||||
|
QJsonObject s = m_config->settingsObj();
|
||||||
|
s.insert("darkMode", dark);
|
||||||
|
m_config->setSettings(s);
|
||||||
|
}
|
||||||
|
|
||||||
|
void ButtonController::setPassword(const QString &pwd)
|
||||||
|
{
|
||||||
|
QJsonObject s = m_config->settingsObj();
|
||||||
|
s.insert("password", pwd);
|
||||||
|
m_config->setSettings(s);
|
||||||
|
}
|
||||||
|
|
||||||
|
QString ButtonController::iconsDir() const
|
||||||
|
{
|
||||||
|
return m_config->iconsDir();
|
||||||
|
}
|
||||||
|
|
||||||
|
QStringList ButtonController::listIcons() const
|
||||||
|
{
|
||||||
|
QDir d(m_config->iconsDir());
|
||||||
|
QStringList filters;
|
||||||
|
filters << "*.png" << "*.jpg" << "*.jpeg" << "*.svg" << "*.bmp";
|
||||||
|
return d.entryList(filters, QDir::Files | QDir::Readable, QDir::Name);
|
||||||
|
}
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
#ifndef BUTTONCONTROLLER_H
|
||||||
|
#define BUTTONCONTROLLER_H
|
||||||
|
|
||||||
|
#include <QObject>
|
||||||
|
#include <QString>
|
||||||
|
#include <QJsonObject>
|
||||||
|
#include <QNetworkAccessManager>
|
||||||
|
|
||||||
|
class ConfigManager;
|
||||||
|
class ButtonsModel;
|
||||||
|
|
||||||
|
class ButtonController : public QObject
|
||||||
|
{
|
||||||
|
Q_OBJECT
|
||||||
|
|
||||||
|
public:
|
||||||
|
explicit ButtonController(ConfigManager *config, ButtonsModel *model, QObject *parent = nullptr);
|
||||||
|
|
||||||
|
Q_INVOKABLE void invokeButton(const QString &id);
|
||||||
|
Q_INVOKABLE bool checkPassword(const QString &pwd) const;
|
||||||
|
|
||||||
|
// CRUD wrappers (so QML can call without touching ConfigManager directly)
|
||||||
|
Q_INVOKABLE void addButton(const QString &label,
|
||||||
|
const QString &iconPath,
|
||||||
|
const QString &actionType,
|
||||||
|
const QString &actionUrl,
|
||||||
|
const QString &successText,
|
||||||
|
const QString &errorText,
|
||||||
|
const QString &color = QString());
|
||||||
|
Q_INVOKABLE void updateButton(const QString &id,
|
||||||
|
const QString &label,
|
||||||
|
const QString &iconPath,
|
||||||
|
const QString &actionType,
|
||||||
|
const QString &actionUrl,
|
||||||
|
const QString &successText,
|
||||||
|
const QString &errorText,
|
||||||
|
const QString &color = QString());
|
||||||
|
Q_INVOKABLE void removeButton(const QString &id);
|
||||||
|
Q_INVOKABLE void moveButton(int from, int to);
|
||||||
|
|
||||||
|
// Layout / background helpers
|
||||||
|
Q_INVOKABLE void setLayoutMode(const QString &mode);
|
||||||
|
Q_INVOKABLE void setLayoutColumns(int columns);
|
||||||
|
Q_INVOKABLE void setLayoutSpacing(int spacing);
|
||||||
|
Q_INVOKABLE void setLayoutShowLabels(bool show);
|
||||||
|
|
||||||
|
Q_INVOKABLE void setBackgroundType(const QString &type);
|
||||||
|
Q_INVOKABLE void setBackgroundColors(const QString &c1, const QString &c2);
|
||||||
|
Q_INVOKABLE void setBackgroundAnimated(bool animated);
|
||||||
|
Q_INVOKABLE void setBackgroundImage(const QString &path);
|
||||||
|
|
||||||
|
Q_INVOKABLE void setFeedbackColors(const QString &okColor, const QString &errorColor);
|
||||||
|
Q_INVOKABLE void setFeedbackWidths(int okWidth, int errorWidth);
|
||||||
|
Q_INVOKABLE void setFeedbackGlowRadius(int radius);
|
||||||
|
|
||||||
|
Q_INVOKABLE void setDarkMode(bool dark);
|
||||||
|
Q_INVOKABLE void setPassword(const QString &pwd);
|
||||||
|
|
||||||
|
Q_INVOKABLE QString iconsDir() const;
|
||||||
|
Q_INVOKABLE QStringList listIcons() const;
|
||||||
|
|
||||||
|
signals:
|
||||||
|
void buttonInvoked(QString id, bool success, QString message);
|
||||||
|
|
||||||
|
private:
|
||||||
|
ConfigManager *m_config;
|
||||||
|
ButtonsModel *m_model;
|
||||||
|
QNetworkAccessManager *m_nam;
|
||||||
|
};
|
||||||
|
|
||||||
|
#endif // BUTTONCONTROLLER_H
|
||||||
@@ -0,0 +1,125 @@
|
|||||||
|
#include "buttonsmodel.h"
|
||||||
|
#include "configmanager.h"
|
||||||
|
|
||||||
|
#include <QJsonDocument>
|
||||||
|
#include <QFileInfo>
|
||||||
|
#include <QDir>
|
||||||
|
|
||||||
|
ButtonsModel::ButtonsModel(ConfigManager *config, QObject *parent)
|
||||||
|
: QAbstractListModel(parent)
|
||||||
|
, m_config(config)
|
||||||
|
{
|
||||||
|
connect(m_config, &ConfigManager::buttonsChanged, this, &ButtonsModel::reload);
|
||||||
|
reload();
|
||||||
|
}
|
||||||
|
|
||||||
|
void ButtonsModel::reload()
|
||||||
|
{
|
||||||
|
beginResetModel();
|
||||||
|
m_buttons = m_config->buttons();
|
||||||
|
// prune runtime state for removed ids
|
||||||
|
QSet<QString> ids;
|
||||||
|
for (const auto &v : m_buttons) ids.insert(v.toObject().value("id").toString());
|
||||||
|
for (auto it = m_runtime.begin(); it != m_runtime.end(); ) {
|
||||||
|
if (!ids.contains(it.key())) it = m_runtime.erase(it);
|
||||||
|
else ++it;
|
||||||
|
}
|
||||||
|
endResetModel();
|
||||||
|
emit countChanged();
|
||||||
|
}
|
||||||
|
|
||||||
|
int ButtonsModel::rowCount(const QModelIndex &parent) const
|
||||||
|
{
|
||||||
|
if (parent.isValid()) return 0;
|
||||||
|
return m_buttons.size();
|
||||||
|
}
|
||||||
|
|
||||||
|
QVariant ButtonsModel::data(const QModelIndex &index, int role) const
|
||||||
|
{
|
||||||
|
if (!index.isValid() || index.row() < 0 || index.row() >= m_buttons.size())
|
||||||
|
return {};
|
||||||
|
QJsonObject b = m_buttons.at(index.row()).toObject();
|
||||||
|
QString id = b.value("id").toString();
|
||||||
|
QJsonObject action = b.value("action").toObject();
|
||||||
|
QJsonObject feedback = b.value("feedback").toObject();
|
||||||
|
RuntimeState rt = m_runtime.value(id);
|
||||||
|
|
||||||
|
switch (role) {
|
||||||
|
case IdRole: return id;
|
||||||
|
case LabelRole: return b.value("label").toString();
|
||||||
|
case IconPathRole: {
|
||||||
|
QString p = b.value("iconPath").toString();
|
||||||
|
if (p.isEmpty()) return QString();
|
||||||
|
if (p.startsWith("qrc:/") || p.startsWith(":/") || p.startsWith("file://") || p.startsWith("http"))
|
||||||
|
return p;
|
||||||
|
if (QFileInfo(p).isAbsolute())
|
||||||
|
return QString("file:///") + p;
|
||||||
|
return QString("file:///") + QDir(m_config->iconsDir()).filePath(p);
|
||||||
|
}
|
||||||
|
case ActionTypeRole: return action.value("type").toString("http_get");
|
||||||
|
case ActionUrlRole: return action.value("url").toString();
|
||||||
|
case ActionHeadersRole: return QJsonDocument(action.value("headers").toObject()).toJson(QJsonDocument::Compact);
|
||||||
|
case ActionBodyRole: return action.value("body").toString();
|
||||||
|
case SuccessTextRole: return feedback.value("successText").toString();
|
||||||
|
case ErrorTextRole: return feedback.value("errorText").toString();
|
||||||
|
case FadeMsRole: return feedback.value("fadeMs").toInt(5000);
|
||||||
|
case StatusRole: return rt.status;
|
||||||
|
case StatusTextRole: return rt.text;
|
||||||
|
case BtnColorRole: return b.value("color").toString("#7b007b");
|
||||||
|
}
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
|
QHash<int, QByteArray> ButtonsModel::roleNames() const
|
||||||
|
{
|
||||||
|
return {
|
||||||
|
{IdRole, "btnId"},
|
||||||
|
{LabelRole, "label"},
|
||||||
|
{IconPathRole, "iconPath"},
|
||||||
|
{ActionTypeRole, "actionType"},
|
||||||
|
{ActionUrlRole, "actionUrl"},
|
||||||
|
{ActionHeadersRole, "actionHeaders"},
|
||||||
|
{ActionBodyRole, "actionBody"},
|
||||||
|
{SuccessTextRole, "successText"},
|
||||||
|
{ErrorTextRole, "errorText"},
|
||||||
|
{FadeMsRole, "fadeMs"},
|
||||||
|
{StatusRole, "status"},
|
||||||
|
{StatusTextRole, "statusText"},
|
||||||
|
{BtnColorRole, "btnColor"}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
QVariantMap ButtonsModel::get(int row) const
|
||||||
|
{
|
||||||
|
QVariantMap m;
|
||||||
|
if (row < 0 || row >= m_buttons.size()) return m;
|
||||||
|
auto rn = roleNames();
|
||||||
|
for (auto it = rn.begin(); it != rn.end(); ++it)
|
||||||
|
m.insert(QString::fromUtf8(it.value()), data(index(row, 0), it.key()));
|
||||||
|
return m;
|
||||||
|
}
|
||||||
|
|
||||||
|
int ButtonsModel::indexOfId(const QString &id) const
|
||||||
|
{
|
||||||
|
for (int i = 0; i < m_buttons.size(); ++i)
|
||||||
|
if (m_buttons.at(i).toObject().value("id").toString() == id)
|
||||||
|
return i;
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
void ButtonsModel::setStatus(const QString &id, int status, const QString &statusText)
|
||||||
|
{
|
||||||
|
int row = indexOfId(id);
|
||||||
|
if (row < 0) return;
|
||||||
|
RuntimeState &rt = m_runtime[id];
|
||||||
|
rt.status = status;
|
||||||
|
rt.text = statusText;
|
||||||
|
QModelIndex idx = index(row, 0);
|
||||||
|
emit dataChanged(idx, idx, {StatusRole, StatusTextRole});
|
||||||
|
}
|
||||||
|
|
||||||
|
QVariantMap ButtonsModel::buttonToVariant(const QJsonObject &b, int row) const
|
||||||
|
{
|
||||||
|
Q_UNUSED(b); Q_UNUSED(row);
|
||||||
|
return {};
|
||||||
|
}
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
#ifndef BUTTONSMODEL_H
|
||||||
|
#define BUTTONSMODEL_H
|
||||||
|
|
||||||
|
#include <QAbstractListModel>
|
||||||
|
#include <QJsonObject>
|
||||||
|
#include <QJsonArray>
|
||||||
|
|
||||||
|
class ConfigManager;
|
||||||
|
|
||||||
|
class ButtonsModel : public QAbstractListModel
|
||||||
|
{
|
||||||
|
Q_OBJECT
|
||||||
|
Q_PROPERTY(int count READ rowCount NOTIFY countChanged)
|
||||||
|
|
||||||
|
public:
|
||||||
|
enum Roles {
|
||||||
|
IdRole = Qt::UserRole + 1,
|
||||||
|
LabelRole,
|
||||||
|
IconPathRole,
|
||||||
|
ActionTypeRole,
|
||||||
|
ActionUrlRole,
|
||||||
|
ActionHeadersRole,
|
||||||
|
ActionBodyRole,
|
||||||
|
SuccessTextRole,
|
||||||
|
ErrorTextRole,
|
||||||
|
FadeMsRole,
|
||||||
|
StatusRole, // 0=idle, 1=ok, 2=error, 3=in-progress
|
||||||
|
StatusTextRole,
|
||||||
|
BtnColorRole
|
||||||
|
};
|
||||||
|
|
||||||
|
explicit ButtonsModel(ConfigManager *config, QObject *parent = nullptr);
|
||||||
|
|
||||||
|
int rowCount(const QModelIndex &parent = QModelIndex()) const override;
|
||||||
|
QVariant data(const QModelIndex &index, int role) const override;
|
||||||
|
QHash<int, QByteArray> roleNames() const override;
|
||||||
|
|
||||||
|
Q_INVOKABLE QVariantMap get(int row) const;
|
||||||
|
Q_INVOKABLE int indexOfId(const QString &id) const;
|
||||||
|
|
||||||
|
// Per-button transient runtime state (status / status text)
|
||||||
|
void setStatus(const QString &id, int status, const QString &statusText);
|
||||||
|
|
||||||
|
signals:
|
||||||
|
void countChanged();
|
||||||
|
|
||||||
|
private slots:
|
||||||
|
void reload();
|
||||||
|
|
||||||
|
private:
|
||||||
|
QVariantMap buttonToVariant(const QJsonObject &b, int row) const;
|
||||||
|
|
||||||
|
ConfigManager *m_config;
|
||||||
|
QJsonArray m_buttons;
|
||||||
|
// runtime: id -> {status, text}
|
||||||
|
struct RuntimeState { int status = 0; QString text; };
|
||||||
|
QHash<QString, RuntimeState> m_runtime;
|
||||||
|
};
|
||||||
|
|
||||||
|
#endif // BUTTONSMODEL_H
|
||||||
@@ -0,0 +1,280 @@
|
|||||||
|
#include "configmanager.h"
|
||||||
|
|
||||||
|
#include <QFile>
|
||||||
|
#include <QSaveFile>
|
||||||
|
#include <QJsonDocument>
|
||||||
|
#include <QJsonParseError>
|
||||||
|
#include <QDir>
|
||||||
|
#include <QFileInfo>
|
||||||
|
#include <QCoreApplication>
|
||||||
|
#include <QDebug>
|
||||||
|
#include <QUuid>
|
||||||
|
|
||||||
|
ConfigManager::ConfigManager(const QString &configPath, QObject *parent)
|
||||||
|
: QObject(parent)
|
||||||
|
, m_configPath(configPath)
|
||||||
|
{
|
||||||
|
connect(&m_watcher, &QFileSystemWatcher::fileChanged,
|
||||||
|
this, &ConfigManager::onFileChanged);
|
||||||
|
load();
|
||||||
|
}
|
||||||
|
|
||||||
|
bool ConfigManager::load()
|
||||||
|
{
|
||||||
|
QFile f(m_configPath);
|
||||||
|
if (!f.exists()) {
|
||||||
|
qWarning() << "Config not found at" << m_configPath << "- creating defaults";
|
||||||
|
ensureDefaults();
|
||||||
|
save();
|
||||||
|
rewatch();
|
||||||
|
emit configChanged();
|
||||||
|
emit buttonsChanged();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (!f.open(QIODevice::ReadOnly)) {
|
||||||
|
qWarning() << "Cannot open config:" << f.errorString();
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
QByteArray raw = f.readAll();
|
||||||
|
f.close();
|
||||||
|
|
||||||
|
QJsonParseError err;
|
||||||
|
QJsonDocument doc = QJsonDocument::fromJson(raw, &err);
|
||||||
|
if (err.error != QJsonParseError::NoError || !doc.isObject()) {
|
||||||
|
qWarning() << "Config parse error:" << err.errorString();
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
m_root = doc.object();
|
||||||
|
ensureDefaults();
|
||||||
|
rewatch();
|
||||||
|
emit configChanged();
|
||||||
|
emit buttonsChanged();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool ConfigManager::save()
|
||||||
|
{
|
||||||
|
QFileInfo fi(m_configPath);
|
||||||
|
QDir().mkpath(fi.absolutePath());
|
||||||
|
|
||||||
|
QSaveFile f(m_configPath);
|
||||||
|
if (!f.open(QIODevice::WriteOnly | QIODevice::Truncate)) {
|
||||||
|
qWarning() << "Cannot open config for writing:" << f.errorString();
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
m_writingOurselves = true;
|
||||||
|
QJsonDocument doc(m_root);
|
||||||
|
f.write(doc.toJson(QJsonDocument::Indented));
|
||||||
|
bool ok = f.commit();
|
||||||
|
m_writingOurselves = false;
|
||||||
|
if (!ok) {
|
||||||
|
qWarning() << "Failed to commit config:" << f.errorString();
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
rewatch();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
void ConfigManager::onFileChanged(const QString &path)
|
||||||
|
{
|
||||||
|
Q_UNUSED(path);
|
||||||
|
if (m_writingOurselves)
|
||||||
|
return;
|
||||||
|
// Debounce: re-add watcher (some editors replace file)
|
||||||
|
rewatch();
|
||||||
|
QFile f(m_configPath);
|
||||||
|
if (!f.exists()) return;
|
||||||
|
if (!f.open(QIODevice::ReadOnly)) return;
|
||||||
|
QByteArray raw = f.readAll();
|
||||||
|
f.close();
|
||||||
|
QJsonParseError err;
|
||||||
|
QJsonDocument doc = QJsonDocument::fromJson(raw, &err);
|
||||||
|
if (err.error != QJsonParseError::NoError || !doc.isObject())
|
||||||
|
return;
|
||||||
|
QJsonObject newRoot = doc.object();
|
||||||
|
if (newRoot == m_root)
|
||||||
|
return;
|
||||||
|
m_root = newRoot;
|
||||||
|
ensureDefaults();
|
||||||
|
emit configChanged();
|
||||||
|
emit buttonsChanged();
|
||||||
|
}
|
||||||
|
|
||||||
|
void ConfigManager::rewatch()
|
||||||
|
{
|
||||||
|
if (!m_watcher.files().isEmpty())
|
||||||
|
m_watcher.removePaths(m_watcher.files());
|
||||||
|
if (QFile::exists(m_configPath))
|
||||||
|
m_watcher.addPath(m_configPath);
|
||||||
|
}
|
||||||
|
|
||||||
|
void ConfigManager::ensureDefaults()
|
||||||
|
{
|
||||||
|
if (!m_root.contains("version"))
|
||||||
|
m_root.insert("version", 2);
|
||||||
|
|
||||||
|
QJsonObject layout = m_root.value("layout").toObject();
|
||||||
|
if (!layout.contains("mode")) layout.insert("mode", "grid");
|
||||||
|
if (!layout.contains("columns")) layout.insert("columns", 2);
|
||||||
|
if (!layout.contains("spacing")) layout.insert("spacing", 10);
|
||||||
|
if (!layout.contains("showLabels")) layout.insert("showLabels", true);
|
||||||
|
m_root.insert("layout", layout);
|
||||||
|
|
||||||
|
QJsonObject bg = m_root.value("background").toObject();
|
||||||
|
if (!bg.contains("type")) bg.insert("type", "gradient");
|
||||||
|
if (!bg.contains("color1")) bg.insert("color1", "#00007b");
|
||||||
|
if (!bg.contains("color2")) bg.insert("color2", "#7b007b");
|
||||||
|
if (!bg.contains("animated")) bg.insert("animated", true);
|
||||||
|
if (!bg.contains("imagePath")) bg.insert("imagePath", "");
|
||||||
|
m_root.insert("background", bg);
|
||||||
|
|
||||||
|
QJsonObject fb = m_root.value("feedback").toObject();
|
||||||
|
if (!fb.contains("okColor")) fb.insert("okColor", "#00cc44");
|
||||||
|
if (!fb.contains("errorColor")) fb.insert("errorColor", "#cc2200");
|
||||||
|
if (!fb.contains("okWidth")) fb.insert("okWidth", 6);
|
||||||
|
if (!fb.contains("errorWidth")) fb.insert("errorWidth", 6);
|
||||||
|
if (!fb.contains("glowRadius")) fb.insert("glowRadius", 20);
|
||||||
|
m_root.insert("feedback", fb);
|
||||||
|
|
||||||
|
QJsonObject st = m_root.value("settings").toObject();
|
||||||
|
if (!st.contains("darkMode")) st.insert("darkMode", true);
|
||||||
|
if (!st.contains("password")) st.insert("password", "admin");
|
||||||
|
if (!st.contains("kioskMode")) st.insert("kioskMode", false);
|
||||||
|
if (!st.contains("iconsDir")) {
|
||||||
|
QString defDir = QCoreApplication::applicationDirPath() + "/icons";
|
||||||
|
st.insert("iconsDir", defDir);
|
||||||
|
}
|
||||||
|
if (!st.contains("webPort")) st.insert("webPort", 8080);
|
||||||
|
m_root.insert("settings", st);
|
||||||
|
|
||||||
|
if (!m_root.contains("buttons") || !m_root.value("buttons").isArray())
|
||||||
|
m_root.insert("buttons", QJsonArray());
|
||||||
|
|
||||||
|
// ensure ids on each button
|
||||||
|
QJsonArray btns = m_root.value("buttons").toArray();
|
||||||
|
bool changed = false;
|
||||||
|
for (int i = 0; i < btns.size(); ++i) {
|
||||||
|
QJsonObject b = btns[i].toObject();
|
||||||
|
if (!b.contains("id") || b.value("id").toString().isEmpty()) {
|
||||||
|
b.insert("id", QUuid::createUuid().toString(QUuid::WithoutBraces));
|
||||||
|
btns[i] = b;
|
||||||
|
changed = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (changed) m_root.insert("buttons", btns);
|
||||||
|
|
||||||
|
QDir().mkpath(iconsDir());
|
||||||
|
}
|
||||||
|
|
||||||
|
QJsonArray ConfigManager::buttons() const
|
||||||
|
{
|
||||||
|
return m_root.value("buttons").toArray();
|
||||||
|
}
|
||||||
|
|
||||||
|
QJsonObject ConfigManager::layoutObj() const
|
||||||
|
{
|
||||||
|
return m_root.value("layout").toObject();
|
||||||
|
}
|
||||||
|
|
||||||
|
QJsonObject ConfigManager::backgroundObj() const
|
||||||
|
{
|
||||||
|
return m_root.value("background").toObject();
|
||||||
|
}
|
||||||
|
|
||||||
|
QJsonObject ConfigManager::feedbackObj() const
|
||||||
|
{
|
||||||
|
return m_root.value("feedback").toObject();
|
||||||
|
}
|
||||||
|
|
||||||
|
QJsonObject ConfigManager::settingsObj() const
|
||||||
|
{
|
||||||
|
return m_root.value("settings").toObject();
|
||||||
|
}
|
||||||
|
|
||||||
|
QString ConfigManager::iconsDir() const
|
||||||
|
{
|
||||||
|
return settingsObj().value("iconsDir").toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
void ConfigManager::setButtons(const QJsonArray &arr)
|
||||||
|
{
|
||||||
|
m_root.insert("buttons", arr);
|
||||||
|
save();
|
||||||
|
emit configChanged();
|
||||||
|
emit buttonsChanged();
|
||||||
|
}
|
||||||
|
|
||||||
|
void ConfigManager::setLayout(const QJsonObject &obj)
|
||||||
|
{
|
||||||
|
m_root.insert("layout", obj);
|
||||||
|
save();
|
||||||
|
emit configChanged();
|
||||||
|
}
|
||||||
|
|
||||||
|
void ConfigManager::setBackground(const QJsonObject &obj)
|
||||||
|
{
|
||||||
|
m_root.insert("background", obj);
|
||||||
|
save();
|
||||||
|
emit configChanged();
|
||||||
|
}
|
||||||
|
|
||||||
|
void ConfigManager::setFeedback(const QJsonObject &obj)
|
||||||
|
{
|
||||||
|
m_root.insert("feedback", obj);
|
||||||
|
save();
|
||||||
|
emit configChanged();
|
||||||
|
}
|
||||||
|
|
||||||
|
void ConfigManager::setSettings(const QJsonObject &obj)
|
||||||
|
{
|
||||||
|
m_root.insert("settings", obj);
|
||||||
|
save();
|
||||||
|
emit configChanged();
|
||||||
|
}
|
||||||
|
|
||||||
|
void ConfigManager::addButton(const QJsonObject &btn)
|
||||||
|
{
|
||||||
|
QJsonArray arr = buttons();
|
||||||
|
QJsonObject b = btn;
|
||||||
|
if (!b.contains("id") || b.value("id").toString().isEmpty())
|
||||||
|
b.insert("id", QUuid::createUuid().toString(QUuid::WithoutBraces));
|
||||||
|
arr.append(b);
|
||||||
|
setButtons(arr);
|
||||||
|
}
|
||||||
|
|
||||||
|
void ConfigManager::removeButton(const QString &id)
|
||||||
|
{
|
||||||
|
QJsonArray arr = buttons();
|
||||||
|
for (int i = 0; i < arr.size(); ++i) {
|
||||||
|
if (arr[i].toObject().value("id").toString() == id) {
|
||||||
|
arr.removeAt(i);
|
||||||
|
setButtons(arr);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void ConfigManager::updateButton(const QString &id, const QJsonObject &btn)
|
||||||
|
{
|
||||||
|
QJsonArray arr = buttons();
|
||||||
|
for (int i = 0; i < arr.size(); ++i) {
|
||||||
|
if (arr[i].toObject().value("id").toString() == id) {
|
||||||
|
QJsonObject merged = btn;
|
||||||
|
merged.insert("id", id);
|
||||||
|
arr[i] = merged;
|
||||||
|
setButtons(arr);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void ConfigManager::moveButton(int from, int to)
|
||||||
|
{
|
||||||
|
QJsonArray arr = buttons();
|
||||||
|
if (from < 0 || from >= arr.size() || to < 0 || to >= arr.size() || from == to)
|
||||||
|
return;
|
||||||
|
QJsonValue v = arr.at(from);
|
||||||
|
arr.removeAt(from);
|
||||||
|
arr.insert(to, v);
|
||||||
|
setButtons(arr);
|
||||||
|
}
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
#ifndef CONFIGMANAGER_H
|
||||||
|
#define CONFIGMANAGER_H
|
||||||
|
|
||||||
|
#include <QObject>
|
||||||
|
#include <QString>
|
||||||
|
#include <QJsonObject>
|
||||||
|
#include <QJsonArray>
|
||||||
|
#include <QFileSystemWatcher>
|
||||||
|
|
||||||
|
class ConfigManager : public QObject
|
||||||
|
{
|
||||||
|
Q_OBJECT
|
||||||
|
Q_PROPERTY(QJsonObject layout READ layoutObj NOTIFY configChanged)
|
||||||
|
Q_PROPERTY(QJsonObject background READ backgroundObj NOTIFY configChanged)
|
||||||
|
Q_PROPERTY(QJsonObject feedback READ feedbackObj NOTIFY configChanged)
|
||||||
|
Q_PROPERTY(QJsonObject settings READ settingsObj NOTIFY configChanged)
|
||||||
|
Q_PROPERTY(QString iconsDir READ iconsDir NOTIFY configChanged)
|
||||||
|
Q_PROPERTY(QString configPath READ configPath CONSTANT)
|
||||||
|
|
||||||
|
public:
|
||||||
|
explicit ConfigManager(const QString &configPath, QObject *parent = nullptr);
|
||||||
|
|
||||||
|
bool load();
|
||||||
|
bool save();
|
||||||
|
|
||||||
|
QJsonObject root() const { return m_root; }
|
||||||
|
QJsonArray buttons() const;
|
||||||
|
QJsonObject layoutObj() const;
|
||||||
|
QJsonObject backgroundObj() const;
|
||||||
|
QJsonObject feedbackObj() const;
|
||||||
|
QJsonObject settingsObj() const;
|
||||||
|
|
||||||
|
QString configPath() const { return m_configPath; }
|
||||||
|
QString iconsDir() const;
|
||||||
|
|
||||||
|
// Mutators (used by QML/ButtonController/SettingsContainer)
|
||||||
|
void setButtons(const QJsonArray &arr);
|
||||||
|
void setLayout(const QJsonObject &obj);
|
||||||
|
void setBackground(const QJsonObject &obj);
|
||||||
|
void setFeedback(const QJsonObject &obj);
|
||||||
|
void setSettings(const QJsonObject &obj);
|
||||||
|
|
||||||
|
// Convenience
|
||||||
|
void addButton(const QJsonObject &btn);
|
||||||
|
void removeButton(const QString &id);
|
||||||
|
void updateButton(const QString &id, const QJsonObject &btn);
|
||||||
|
void moveButton(int from, int to);
|
||||||
|
|
||||||
|
signals:
|
||||||
|
void configChanged();
|
||||||
|
void buttonsChanged();
|
||||||
|
|
||||||
|
private slots:
|
||||||
|
void onFileChanged(const QString &path);
|
||||||
|
|
||||||
|
private:
|
||||||
|
void ensureDefaults();
|
||||||
|
void rewatch();
|
||||||
|
|
||||||
|
QString m_configPath;
|
||||||
|
QJsonObject m_root;
|
||||||
|
QFileSystemWatcher m_watcher;
|
||||||
|
bool m_writingOurselves = false;
|
||||||
|
};
|
||||||
|
|
||||||
|
#endif // CONFIGMANAGER_H
|
||||||
@@ -0,0 +1,99 @@
|
|||||||
|
#include "settingscontainer.h"
|
||||||
|
#include "configmanager.h"
|
||||||
|
|
||||||
|
SettingsContainer::SettingsContainer(ConfigManager *config, QObject *parent)
|
||||||
|
: QObject(parent)
|
||||||
|
, m_config(config)
|
||||||
|
{
|
||||||
|
connect(m_config, &ConfigManager::configChanged, this, &SettingsContainer::changed);
|
||||||
|
}
|
||||||
|
|
||||||
|
bool SettingsContainer::darkMode() const
|
||||||
|
{
|
||||||
|
return m_config->settingsObj().value("darkMode").toBool(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
bool SettingsContainer::kioskMode() const
|
||||||
|
{
|
||||||
|
return m_config->settingsObj().value("kioskMode").toBool(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
int SettingsContainer::webPort() const
|
||||||
|
{
|
||||||
|
return m_config->settingsObj().value("webPort").toInt(8080);
|
||||||
|
}
|
||||||
|
|
||||||
|
QString SettingsContainer::iconsDir() const
|
||||||
|
{
|
||||||
|
return m_config->iconsDir();
|
||||||
|
}
|
||||||
|
|
||||||
|
QString SettingsContainer::layoutMode() const
|
||||||
|
{
|
||||||
|
return m_config->layoutObj().value("mode").toString("grid");
|
||||||
|
}
|
||||||
|
|
||||||
|
int SettingsContainer::layoutColumns() const
|
||||||
|
{
|
||||||
|
return m_config->layoutObj().value("columns").toInt(2);
|
||||||
|
}
|
||||||
|
|
||||||
|
int SettingsContainer::layoutSpacing() const
|
||||||
|
{
|
||||||
|
return m_config->layoutObj().value("spacing").toInt(10);
|
||||||
|
}
|
||||||
|
|
||||||
|
bool SettingsContainer::showLabels() const
|
||||||
|
{
|
||||||
|
return m_config->layoutObj().value("showLabels").toBool(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
QString SettingsContainer::backgroundType() const
|
||||||
|
{
|
||||||
|
return m_config->backgroundObj().value("type").toString("gradient");
|
||||||
|
}
|
||||||
|
|
||||||
|
QString SettingsContainer::backgroundColor1() const
|
||||||
|
{
|
||||||
|
return m_config->backgroundObj().value("color1").toString("#00007b");
|
||||||
|
}
|
||||||
|
|
||||||
|
QString SettingsContainer::backgroundColor2() const
|
||||||
|
{
|
||||||
|
return m_config->backgroundObj().value("color2").toString("#7b007b");
|
||||||
|
}
|
||||||
|
|
||||||
|
bool SettingsContainer::backgroundAnimated() const
|
||||||
|
{
|
||||||
|
return m_config->backgroundObj().value("animated").toBool(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
QString SettingsContainer::backgroundImage() const
|
||||||
|
{
|
||||||
|
return m_config->backgroundObj().value("imagePath").toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
QString SettingsContainer::feedbackOkColor() const
|
||||||
|
{
|
||||||
|
return m_config->feedbackObj().value("okColor").toString("#00cc44");
|
||||||
|
}
|
||||||
|
|
||||||
|
QString SettingsContainer::feedbackErrorColor() const
|
||||||
|
{
|
||||||
|
return m_config->feedbackObj().value("errorColor").toString("#cc2200");
|
||||||
|
}
|
||||||
|
|
||||||
|
int SettingsContainer::feedbackOkWidth() const
|
||||||
|
{
|
||||||
|
return m_config->feedbackObj().value("okWidth").toInt(6);
|
||||||
|
}
|
||||||
|
|
||||||
|
int SettingsContainer::feedbackErrorWidth() const
|
||||||
|
{
|
||||||
|
return m_config->feedbackObj().value("errorWidth").toInt(6);
|
||||||
|
}
|
||||||
|
|
||||||
|
int SettingsContainer::feedbackGlowRadius() const
|
||||||
|
{
|
||||||
|
return m_config->feedbackObj().value("glowRadius").toInt(20);
|
||||||
|
}
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
#ifndef SETTINGSCONTAINER_H
|
||||||
|
#define SETTINGSCONTAINER_H
|
||||||
|
|
||||||
|
#include <QObject>
|
||||||
|
|
||||||
|
class ConfigManager;
|
||||||
|
|
||||||
|
class SettingsContainer : public QObject
|
||||||
|
{
|
||||||
|
Q_OBJECT
|
||||||
|
Q_PROPERTY(bool darkMode READ darkMode NOTIFY changed)
|
||||||
|
Q_PROPERTY(bool kioskMode READ kioskMode NOTIFY changed)
|
||||||
|
Q_PROPERTY(int webPort READ webPort NOTIFY changed)
|
||||||
|
Q_PROPERTY(QString iconsDir READ iconsDir NOTIFY changed)
|
||||||
|
Q_PROPERTY(QString layoutMode READ layoutMode NOTIFY changed)
|
||||||
|
Q_PROPERTY(int layoutColumns READ layoutColumns NOTIFY changed)
|
||||||
|
Q_PROPERTY(int layoutSpacing READ layoutSpacing NOTIFY changed)
|
||||||
|
Q_PROPERTY(bool showLabels READ showLabels NOTIFY changed)
|
||||||
|
Q_PROPERTY(QString backgroundType READ backgroundType NOTIFY changed)
|
||||||
|
Q_PROPERTY(QString backgroundColor1 READ backgroundColor1 NOTIFY changed)
|
||||||
|
Q_PROPERTY(QString backgroundColor2 READ backgroundColor2 NOTIFY changed)
|
||||||
|
Q_PROPERTY(bool backgroundAnimated READ backgroundAnimated NOTIFY changed)
|
||||||
|
Q_PROPERTY(QString backgroundImage READ backgroundImage NOTIFY changed)
|
||||||
|
Q_PROPERTY(QString feedbackOkColor READ feedbackOkColor NOTIFY changed)
|
||||||
|
Q_PROPERTY(QString feedbackErrorColor READ feedbackErrorColor NOTIFY changed)
|
||||||
|
Q_PROPERTY(int feedbackOkWidth READ feedbackOkWidth NOTIFY changed)
|
||||||
|
Q_PROPERTY(int feedbackErrorWidth READ feedbackErrorWidth NOTIFY changed)
|
||||||
|
Q_PROPERTY(int feedbackGlowRadius READ feedbackGlowRadius NOTIFY changed)
|
||||||
|
|
||||||
|
public:
|
||||||
|
explicit SettingsContainer(ConfigManager *config, QObject *parent = nullptr);
|
||||||
|
|
||||||
|
bool darkMode() const;
|
||||||
|
bool kioskMode() const;
|
||||||
|
int webPort() const;
|
||||||
|
QString iconsDir() const;
|
||||||
|
|
||||||
|
QString layoutMode() const;
|
||||||
|
int layoutColumns() const;
|
||||||
|
int layoutSpacing() const;
|
||||||
|
bool showLabels() const;
|
||||||
|
|
||||||
|
QString backgroundType() const;
|
||||||
|
QString backgroundColor1() const;
|
||||||
|
QString backgroundColor2() const;
|
||||||
|
bool backgroundAnimated() const;
|
||||||
|
QString backgroundImage() const;
|
||||||
|
|
||||||
|
QString feedbackOkColor() const;
|
||||||
|
QString feedbackErrorColor() const;
|
||||||
|
int feedbackOkWidth() const;
|
||||||
|
int feedbackErrorWidth() const;
|
||||||
|
int feedbackGlowRadius() const;
|
||||||
|
|
||||||
|
signals:
|
||||||
|
void changed();
|
||||||
|
|
||||||
|
private:
|
||||||
|
ConfigManager *m_config;
|
||||||
|
};
|
||||||
|
|
||||||
|
#endif // SETTINGSCONTAINER_H
|
||||||
@@ -0,0 +1,214 @@
|
|||||||
|
#include "systeminfo.h"
|
||||||
|
|
||||||
|
#include <QCoreApplication>
|
||||||
|
#include <QProcess>
|
||||||
|
#include <QDir>
|
||||||
|
#include <QFile>
|
||||||
|
#include <QFileInfo>
|
||||||
|
#include <QSaveFile>
|
||||||
|
#include <QJsonDocument>
|
||||||
|
#include <QJsonObject>
|
||||||
|
#include <QDateTime>
|
||||||
|
#include <QProcessEnvironment>
|
||||||
|
#include <QRegExp>
|
||||||
|
#include <QHostAddress>
|
||||||
|
|
||||||
|
SystemInfo::SystemInfo(QObject *parent)
|
||||||
|
: QObject(parent)
|
||||||
|
{
|
||||||
|
// Heartbeat for the OTA health-check: touch run/qt.alive periodically.
|
||||||
|
writeHeartbeat();
|
||||||
|
connect(&m_heartbeat, &QTimer::timeout, this, &SystemInfo::writeHeartbeat);
|
||||||
|
m_heartbeat.start(5000);
|
||||||
|
}
|
||||||
|
|
||||||
|
QString SystemInfo::installRoot() const
|
||||||
|
{
|
||||||
|
QString env = qEnvironmentVariable("BUTTONTASK_ROOT");
|
||||||
|
if (!env.isEmpty())
|
||||||
|
return env;
|
||||||
|
return QStringLiteral("/opt/buttontask");
|
||||||
|
}
|
||||||
|
|
||||||
|
QString SystemInfo::runDir() const
|
||||||
|
{
|
||||||
|
QString d = installRoot() + "/run";
|
||||||
|
QDir().mkpath(d);
|
||||||
|
return d;
|
||||||
|
}
|
||||||
|
|
||||||
|
QString SystemInfo::scriptsDir() const
|
||||||
|
{
|
||||||
|
// Prefer the installed scripts under <root>/scripts; fall back to the copy
|
||||||
|
// shipped next to the binary (dev / source tree).
|
||||||
|
QString installed = installRoot() + "/scripts";
|
||||||
|
if (QFileInfo::exists(installed))
|
||||||
|
return installed;
|
||||||
|
QString local = QCoreApplication::applicationDirPath() + "/webconfig/scripts";
|
||||||
|
if (QFileInfo::exists(local))
|
||||||
|
return local;
|
||||||
|
return installed;
|
||||||
|
}
|
||||||
|
|
||||||
|
void SystemInfo::writeHeartbeat()
|
||||||
|
{
|
||||||
|
QSaveFile f(runDir() + "/qt.alive");
|
||||||
|
if (!f.open(QIODevice::WriteOnly | QIODevice::Truncate))
|
||||||
|
return;
|
||||||
|
QByteArray ts = QByteArray::number(QDateTime::currentSecsSinceEpoch());
|
||||||
|
f.write(ts);
|
||||||
|
f.commit();
|
||||||
|
}
|
||||||
|
|
||||||
|
QString SystemInfo::appVersion() const
|
||||||
|
{
|
||||||
|
return QCoreApplication::applicationVersion();
|
||||||
|
}
|
||||||
|
|
||||||
|
QString SystemInfo::currentVersion() const
|
||||||
|
{
|
||||||
|
QString manifestPath = installRoot() + "/current/manifest.json";
|
||||||
|
QFile f(manifestPath);
|
||||||
|
if (!f.open(QIODevice::ReadOnly))
|
||||||
|
return appVersion();
|
||||||
|
QJsonDocument doc = QJsonDocument::fromJson(f.readAll());
|
||||||
|
f.close();
|
||||||
|
if (!doc.isObject())
|
||||||
|
return appVersion();
|
||||||
|
return doc.object().value("version").toString(appVersion());
|
||||||
|
}
|
||||||
|
|
||||||
|
QVariantList SystemInfo::networkInfo() const
|
||||||
|
{
|
||||||
|
QVariantList result;
|
||||||
|
QProcess proc;
|
||||||
|
proc.start("nmcli", {"-t", "-f",
|
||||||
|
"DEVICE,TYPE,STATE,CONNECTION",
|
||||||
|
"device", "status"});
|
||||||
|
if (!proc.waitForStarted(2000))
|
||||||
|
return result;
|
||||||
|
if (!proc.waitForFinished(4000))
|
||||||
|
return result;
|
||||||
|
const QString out = QString::fromUtf8(proc.readAllStandardOutput());
|
||||||
|
const QStringList lines = out.split('\n', QString::SkipEmptyParts);
|
||||||
|
for (const QString &line : lines) {
|
||||||
|
const QStringList parts = line.split(':');
|
||||||
|
if (parts.size() < 4)
|
||||||
|
continue;
|
||||||
|
if (parts.at(1) != "ethernet")
|
||||||
|
continue;
|
||||||
|
QVariantMap dev;
|
||||||
|
dev.insert("device", parts.at(0));
|
||||||
|
dev.insert("state", parts.at(2));
|
||||||
|
dev.insert("connection", parts.at(3));
|
||||||
|
|
||||||
|
// Per-device IPv4 details.
|
||||||
|
QProcess det;
|
||||||
|
det.start("nmcli", {"-t", "-f", "IP4.ADDRESS,IP4.GATEWAY,IP4.DNS",
|
||||||
|
"device", "show", parts.at(0)});
|
||||||
|
if (det.waitForStarted(1500) && det.waitForFinished(3000)) {
|
||||||
|
const QStringList dlines =
|
||||||
|
QString::fromUtf8(det.readAllStandardOutput())
|
||||||
|
.split('\n', QString::SkipEmptyParts);
|
||||||
|
QStringList addrs, dns;
|
||||||
|
QString gw;
|
||||||
|
for (const QString &dl : dlines) {
|
||||||
|
const int colon = dl.indexOf(':');
|
||||||
|
if (colon < 0) continue;
|
||||||
|
const QString key = dl.left(colon);
|
||||||
|
const QString val = dl.mid(colon + 1).trimmed();
|
||||||
|
if (val.isEmpty()) continue;
|
||||||
|
if (key.startsWith("IP4.ADDRESS")) addrs << val;
|
||||||
|
else if (key == "IP4.GATEWAY") gw = val;
|
||||||
|
else if (key.startsWith("IP4.DNS")) dns << val;
|
||||||
|
}
|
||||||
|
dev.insert("addresses", addrs.join(", "));
|
||||||
|
dev.insert("gateway", gw);
|
||||||
|
dev.insert("dns", dns.join(", "));
|
||||||
|
}
|
||||||
|
result.append(dev);
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
QVariantMap SystemInfo::applyNetwork(const QString &iface,
|
||||||
|
const QString &mode,
|
||||||
|
const QString &address,
|
||||||
|
int prefix,
|
||||||
|
const QString &gateway,
|
||||||
|
const QString &dns)
|
||||||
|
{
|
||||||
|
QVariantMap res;
|
||||||
|
res.insert("ok", false);
|
||||||
|
|
||||||
|
// Validation (defence in depth; bt-netconfig validates again).
|
||||||
|
QRegExp ifaceRe("^[A-Za-z0-9_.:-]{1,32}$");
|
||||||
|
if (!ifaceRe.exactMatch(iface)) {
|
||||||
|
res.insert("error", "Некорректное имя интерфейса");
|
||||||
|
return res;
|
||||||
|
}
|
||||||
|
if (mode != "dhcp" && mode != "static") {
|
||||||
|
res.insert("error", "Режим должен быть dhcp или static");
|
||||||
|
return res;
|
||||||
|
}
|
||||||
|
|
||||||
|
QStringList args;
|
||||||
|
args << "-n" << (scriptsDir() + "/bt-netconfig") << iface << mode;
|
||||||
|
if (mode == "static") {
|
||||||
|
QHostAddress addr;
|
||||||
|
if (address.isEmpty() || !addr.setAddress(address)) {
|
||||||
|
res.insert("error", "Некорректный IP-адрес");
|
||||||
|
return res;
|
||||||
|
}
|
||||||
|
if (prefix < 1 || prefix > 32) {
|
||||||
|
res.insert("error", "Префикс должен быть 1..32");
|
||||||
|
return res;
|
||||||
|
}
|
||||||
|
QHostAddress gw;
|
||||||
|
if (!gateway.isEmpty() && !gw.setAddress(gateway)) {
|
||||||
|
res.insert("error", "Некорректный шлюз");
|
||||||
|
return res;
|
||||||
|
}
|
||||||
|
args << address << QString::number(prefix) << gateway << dns;
|
||||||
|
}
|
||||||
|
|
||||||
|
QProcess proc;
|
||||||
|
proc.start("sudo", args);
|
||||||
|
if (!proc.waitForStarted(3000)) {
|
||||||
|
res.insert("error", "Не удалось запустить sudo/bt-netconfig");
|
||||||
|
return res;
|
||||||
|
}
|
||||||
|
if (!proc.waitForFinished(30000)) {
|
||||||
|
proc.kill();
|
||||||
|
res.insert("error", "Таймаут применения настроек");
|
||||||
|
return res;
|
||||||
|
}
|
||||||
|
const QString out = QString::fromUtf8(proc.readAllStandardOutput()).trimmed();
|
||||||
|
const QString errOut = QString::fromUtf8(proc.readAllStandardError()).trimmed();
|
||||||
|
if (proc.exitStatus() != QProcess::NormalExit || proc.exitCode() != 0) {
|
||||||
|
res.insert("error", errOut.isEmpty() ? out : errOut);
|
||||||
|
return res;
|
||||||
|
}
|
||||||
|
res.insert("ok", true);
|
||||||
|
res.insert("output", out);
|
||||||
|
return res;
|
||||||
|
}
|
||||||
|
|
||||||
|
QVariantMap SystemInfo::updateStatus() const
|
||||||
|
{
|
||||||
|
QVariantMap result;
|
||||||
|
result.insert("state", "idle");
|
||||||
|
QFile f(runDir() + "/update-status.json");
|
||||||
|
if (!f.open(QIODevice::ReadOnly))
|
||||||
|
return result;
|
||||||
|
QJsonDocument doc = QJsonDocument::fromJson(f.readAll());
|
||||||
|
f.close();
|
||||||
|
if (doc.isObject())
|
||||||
|
return doc.object().toVariantMap();
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
void SystemInfo::refresh()
|
||||||
|
{
|
||||||
|
emit changed();
|
||||||
|
}
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
#ifndef SYSTEMINFO_H
|
||||||
|
#define SYSTEMINFO_H
|
||||||
|
|
||||||
|
#include <QObject>
|
||||||
|
#include <QString>
|
||||||
|
#include <QVariantList>
|
||||||
|
#include <QVariantMap>
|
||||||
|
#include <QTimer>
|
||||||
|
|
||||||
|
// Read-only system information exposed to QML: application version, current
|
||||||
|
// installed (OTA) version, a live Ethernet summary via nmcli, and the latest
|
||||||
|
// update status. Also writes a periodic heartbeat file consumed by the OTA
|
||||||
|
// health-check to confirm the UI actually started.
|
||||||
|
class SystemInfo : public QObject
|
||||||
|
{
|
||||||
|
Q_OBJECT
|
||||||
|
Q_PROPERTY(QString appVersion READ appVersion CONSTANT)
|
||||||
|
Q_PROPERTY(QString currentVersion READ currentVersion NOTIFY changed)
|
||||||
|
|
||||||
|
public:
|
||||||
|
explicit SystemInfo(QObject *parent = nullptr);
|
||||||
|
|
||||||
|
QString appVersion() const;
|
||||||
|
QString currentVersion() const;
|
||||||
|
|
||||||
|
Q_INVOKABLE QVariantList networkInfo() const;
|
||||||
|
Q_INVOKABLE QVariantMap applyNetwork(const QString &iface,
|
||||||
|
const QString &mode,
|
||||||
|
const QString &address = QString(),
|
||||||
|
int prefix = 24,
|
||||||
|
const QString &gateway = QString(),
|
||||||
|
const QString &dns = QString());
|
||||||
|
Q_INVOKABLE QVariantMap updateStatus() const;
|
||||||
|
Q_INVOKABLE void refresh();
|
||||||
|
|
||||||
|
signals:
|
||||||
|
void changed();
|
||||||
|
|
||||||
|
private:
|
||||||
|
QString installRoot() const;
|
||||||
|
QString runDir() const;
|
||||||
|
QString scriptsDir() const;
|
||||||
|
void writeHeartbeat();
|
||||||
|
|
||||||
|
QTimer m_heartbeat;
|
||||||
|
};
|
||||||
|
|
||||||
|
#endif // SYSTEMINFO_H
|
||||||
@@ -0,0 +1,212 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Core configuration REST API (buttons, layout, background, feedback,
|
||||||
|
settings, icons) for the ButtonTask web configurator."""
|
||||||
|
import uuid
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from flask import Blueprint, abort, jsonify, request, send_from_directory
|
||||||
|
|
||||||
|
from auth import login_required
|
||||||
|
from config_store import (ALLOWED_ICON_EXT, icons_dir, list_icons, load_config,
|
||||||
|
save_config)
|
||||||
|
|
||||||
|
api_bp = Blueprint("api", __name__)
|
||||||
|
|
||||||
|
|
||||||
|
@api_bp.route("/icons/<path:fname>")
|
||||||
|
@login_required
|
||||||
|
def serve_icon(fname):
|
||||||
|
return send_from_directory(str(icons_dir()), fname)
|
||||||
|
|
||||||
|
|
||||||
|
# --- config ---
|
||||||
|
@api_bp.route("/api/config", methods=["GET"])
|
||||||
|
@login_required
|
||||||
|
def api_get_config():
|
||||||
|
return jsonify(load_config())
|
||||||
|
|
||||||
|
|
||||||
|
@api_bp.route("/api/config", methods=["PUT"])
|
||||||
|
@login_required
|
||||||
|
def api_put_config():
|
||||||
|
data = request.get_json(force=True, silent=True)
|
||||||
|
if not isinstance(data, dict):
|
||||||
|
abort(400, "JSON object required")
|
||||||
|
save_config(data)
|
||||||
|
return jsonify({"ok": True})
|
||||||
|
|
||||||
|
|
||||||
|
# --- buttons ---
|
||||||
|
@api_bp.route("/api/buttons", methods=["POST"])
|
||||||
|
@login_required
|
||||||
|
def api_add_button():
|
||||||
|
data = request.get_json(force=True, silent=True) or {}
|
||||||
|
cfg = load_config()
|
||||||
|
btn = {
|
||||||
|
"id": data.get("id") or uuid.uuid4().hex,
|
||||||
|
"label": data.get("label", "Кнопка"),
|
||||||
|
"iconPath": data.get("iconPath", ""),
|
||||||
|
"action": {
|
||||||
|
"type": data.get("actionType", "http_get"),
|
||||||
|
"url": data.get("url", ""),
|
||||||
|
"headers": data.get("headers", {}),
|
||||||
|
"body": data.get("body", ""),
|
||||||
|
},
|
||||||
|
"feedback": {
|
||||||
|
"successText": data.get("successText", ""),
|
||||||
|
"errorText": data.get("errorText", "Ошибка"),
|
||||||
|
"fadeMs": int(data.get("fadeMs", 5000)),
|
||||||
|
},
|
||||||
|
"color": data.get("color", "#7b007b"),
|
||||||
|
}
|
||||||
|
cfg.setdefault("buttons", []).append(btn)
|
||||||
|
save_config(cfg)
|
||||||
|
return jsonify(btn)
|
||||||
|
|
||||||
|
|
||||||
|
@api_bp.route("/api/buttons/<bid>", methods=["PUT"])
|
||||||
|
@login_required
|
||||||
|
def api_update_button(bid):
|
||||||
|
data = request.get_json(force=True, silent=True) or {}
|
||||||
|
cfg = load_config()
|
||||||
|
for b in cfg.get("buttons", []):
|
||||||
|
if b.get("id") == 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"]
|
||||||
|
feedback = b.setdefault("feedback", {})
|
||||||
|
feedback["successText"] = data.get("successText", feedback.get("successText", ""))
|
||||||
|
feedback["errorText"] = data.get("errorText", feedback.get("errorText", "Ошибка"))
|
||||||
|
feedback["fadeMs"] = int(data.get("fadeMs", feedback.get("fadeMs", 5000)))
|
||||||
|
if "color" in data:
|
||||||
|
b["color"] = data["color"]
|
||||||
|
save_config(cfg)
|
||||||
|
return jsonify(b)
|
||||||
|
abort(404)
|
||||||
|
|
||||||
|
|
||||||
|
@api_bp.route("/api/buttons/<bid>", methods=["DELETE"])
|
||||||
|
@login_required
|
||||||
|
def api_delete_button(bid):
|
||||||
|
cfg = load_config()
|
||||||
|
cfg["buttons"] = [b for b in cfg.get("buttons", []) if b.get("id") != bid]
|
||||||
|
save_config(cfg)
|
||||||
|
return jsonify({"ok": True})
|
||||||
|
|
||||||
|
|
||||||
|
@api_bp.route("/api/buttons/reorder", methods=["POST"])
|
||||||
|
@login_required
|
||||||
|
def api_reorder_buttons():
|
||||||
|
data = request.get_json(force=True, silent=True) or {}
|
||||||
|
order = data.get("order", [])
|
||||||
|
cfg = load_config()
|
||||||
|
by_id = {b.get("id"): b for b in cfg.get("buttons", [])}
|
||||||
|
new_list = [by_id[i] for i in order if i in by_id]
|
||||||
|
for b in cfg.get("buttons", []):
|
||||||
|
if b not in new_list:
|
||||||
|
new_list.append(b)
|
||||||
|
cfg["buttons"] = new_list
|
||||||
|
save_config(cfg)
|
||||||
|
return jsonify({"ok": True, "count": len(new_list)})
|
||||||
|
|
||||||
|
|
||||||
|
# --- layout / background / feedback / settings ---
|
||||||
|
@api_bp.route("/api/layout", methods=["PUT"])
|
||||||
|
@login_required
|
||||||
|
def api_set_layout():
|
||||||
|
data = request.get_json(force=True, silent=True) or {}
|
||||||
|
cfg = load_config()
|
||||||
|
layout = cfg.setdefault("layout", {})
|
||||||
|
for k in ("mode", "columns", "spacing", "showLabels"):
|
||||||
|
if k in data:
|
||||||
|
layout[k] = data[k]
|
||||||
|
save_config(cfg)
|
||||||
|
return jsonify(layout)
|
||||||
|
|
||||||
|
|
||||||
|
@api_bp.route("/api/background", methods=["PUT"])
|
||||||
|
@login_required
|
||||||
|
def api_set_background():
|
||||||
|
data = request.get_json(force=True, silent=True) or {}
|
||||||
|
cfg = load_config()
|
||||||
|
bg = cfg.setdefault("background", {})
|
||||||
|
for k in ("type", "color1", "color2", "animated", "imagePath"):
|
||||||
|
if k in data:
|
||||||
|
bg[k] = data[k]
|
||||||
|
save_config(cfg)
|
||||||
|
return jsonify(bg)
|
||||||
|
|
||||||
|
|
||||||
|
@api_bp.route("/api/feedback", methods=["PUT"])
|
||||||
|
@login_required
|
||||||
|
def api_set_feedback():
|
||||||
|
data = request.get_json(force=True, silent=True) or {}
|
||||||
|
cfg = load_config()
|
||||||
|
fb = cfg.setdefault("feedback", {})
|
||||||
|
for k in ("okColor", "errorColor"):
|
||||||
|
if k in data:
|
||||||
|
fb[k] = data[k]
|
||||||
|
for k in ("okWidth", "errorWidth", "glowRadius"):
|
||||||
|
if k in data:
|
||||||
|
try:
|
||||||
|
fb[k] = int(data[k])
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
pass
|
||||||
|
save_config(cfg)
|
||||||
|
return jsonify(fb)
|
||||||
|
|
||||||
|
|
||||||
|
@api_bp.route("/api/settings", methods=["PUT"])
|
||||||
|
@login_required
|
||||||
|
def api_set_settings():
|
||||||
|
data = request.get_json(force=True, silent=True) or {}
|
||||||
|
cfg = load_config()
|
||||||
|
st = cfg.setdefault("settings", {})
|
||||||
|
for k in ("darkMode", "password", "kioskMode", "iconsDir", "webPort"):
|
||||||
|
if k in data:
|
||||||
|
st[k] = data[k]
|
||||||
|
save_config(cfg)
|
||||||
|
return jsonify({"ok": True})
|
||||||
|
|
||||||
|
|
||||||
|
# --- icons ---
|
||||||
|
@api_bp.route("/api/icons", methods=["GET"])
|
||||||
|
@login_required
|
||||||
|
def api_list_icons():
|
||||||
|
return jsonify(list_icons())
|
||||||
|
|
||||||
|
|
||||||
|
@api_bp.route("/api/icons", methods=["POST"])
|
||||||
|
@login_required
|
||||||
|
def api_upload_icon():
|
||||||
|
if "file" not in request.files:
|
||||||
|
abort(400, "file required")
|
||||||
|
fobj = request.files["file"]
|
||||||
|
fname = Path(fobj.filename).name
|
||||||
|
if not fname:
|
||||||
|
abort(400, "empty filename")
|
||||||
|
ext = Path(fname).suffix.lower()
|
||||||
|
if ext not in ALLOWED_ICON_EXT:
|
||||||
|
abort(400, f"extension {ext} not allowed")
|
||||||
|
target = icons_dir() / fname
|
||||||
|
fobj.save(str(target))
|
||||||
|
return jsonify({"ok": True, "name": fname})
|
||||||
|
|
||||||
|
|
||||||
|
@api_bp.route("/api/icons/<path:fname>", methods=["DELETE"])
|
||||||
|
@login_required
|
||||||
|
def api_delete_icon(fname):
|
||||||
|
p = icons_dir() / fname
|
||||||
|
try:
|
||||||
|
p.resolve().relative_to(icons_dir().resolve())
|
||||||
|
except ValueError:
|
||||||
|
abort(400)
|
||||||
|
if p.exists():
|
||||||
|
p.unlink()
|
||||||
|
return jsonify({"ok": True})
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""ButtonTask v2 web configurator (Flask).
|
||||||
|
|
||||||
|
Application factory that wires together the modular blueprints:
|
||||||
|
- auth : login / logout / session
|
||||||
|
- api : buttons, layout, background, feedback, settings, icons
|
||||||
|
- network : Ethernet configuration via nmcli
|
||||||
|
- updater : OTA software update with rollback
|
||||||
|
|
||||||
|
Reads/writes the same JSON config used by the Qt5 application.
|
||||||
|
"""
|
||||||
|
import os
|
||||||
|
import time
|
||||||
|
|
||||||
|
from flask import Flask, jsonify, render_template
|
||||||
|
|
||||||
|
from api import api_bp
|
||||||
|
from auth import auth_bp, load_or_create_secret, login_required
|
||||||
|
from config_store import list_icons, load_config
|
||||||
|
from network import network_bp
|
||||||
|
from updater import current_version, updater_bp
|
||||||
|
|
||||||
|
|
||||||
|
def create_app():
|
||||||
|
app = Flask(__name__, template_folder="templates", static_folder="static")
|
||||||
|
app.secret_key = load_or_create_secret()
|
||||||
|
app.config.update(
|
||||||
|
SESSION_COOKIE_HTTPONLY=True,
|
||||||
|
SESSION_COOKIE_SAMESITE="Lax",
|
||||||
|
PERMANENT_SESSION_LIFETIME=60 * 60 * 8, # 8 hours
|
||||||
|
MAX_CONTENT_LENGTH=512 * 1024 * 1024, # 512 MB upload cap (OTA packages)
|
||||||
|
)
|
||||||
|
|
||||||
|
app.register_blueprint(auth_bp)
|
||||||
|
app.register_blueprint(api_bp)
|
||||||
|
app.register_blueprint(network_bp)
|
||||||
|
app.register_blueprint(updater_bp)
|
||||||
|
|
||||||
|
@app.route("/")
|
||||||
|
@login_required
|
||||||
|
def index():
|
||||||
|
cfg = load_config()
|
||||||
|
return render_template("index.html", config=cfg, icons=list_icons(),
|
||||||
|
version=current_version())
|
||||||
|
|
||||||
|
@app.route("/healthz")
|
||||||
|
def healthz():
|
||||||
|
# Unauthenticated, lightweight: used by the OTA health-check.
|
||||||
|
return jsonify({"ok": True, "ts": time.time(),
|
||||||
|
"version": current_version().get("version")})
|
||||||
|
|
||||||
|
return app
|
||||||
|
|
||||||
|
|
||||||
|
app = create_app()
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
cfg = load_config()
|
||||||
|
port = int(cfg.get("settings", {}).get("webPort", 8080))
|
||||||
|
host = os.environ.get("BUTTONTASK_HOST", "0.0.0.0")
|
||||||
|
app.run(host=host, port=port, debug=False)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Authentication for the ButtonTask web configurator.
|
||||||
|
|
||||||
|
Shares the password from settings.password (form login + session auth).
|
||||||
|
"""
|
||||||
|
import os
|
||||||
|
import secrets
|
||||||
|
from functools import wraps
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from flask import (Blueprint, jsonify, redirect, render_template, request,
|
||||||
|
session, url_for)
|
||||||
|
|
||||||
|
from config_store import config_path, load_config
|
||||||
|
|
||||||
|
auth_bp = Blueprint("auth", __name__)
|
||||||
|
|
||||||
|
|
||||||
|
def load_or_create_secret():
|
||||||
|
"""Persistent secret key so sessions survive restarts."""
|
||||||
|
secret_file = Path(os.environ.get(
|
||||||
|
"BUTTONTASK_SECRET",
|
||||||
|
str(config_path().parent / ".webconfig.secret"),
|
||||||
|
))
|
||||||
|
try:
|
||||||
|
secret_file.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
if secret_file.exists():
|
||||||
|
data = secret_file.read_bytes().strip()
|
||||||
|
if data:
|
||||||
|
return data
|
||||||
|
token = secrets.token_bytes(32)
|
||||||
|
secret_file.write_bytes(token)
|
||||||
|
try:
|
||||||
|
os.chmod(secret_file, 0o600)
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
return token
|
||||||
|
except OSError:
|
||||||
|
return secrets.token_bytes(32)
|
||||||
|
|
||||||
|
|
||||||
|
def is_logged_in():
|
||||||
|
return bool(session.get("authed"))
|
||||||
|
|
||||||
|
|
||||||
|
def login_required(f):
|
||||||
|
@wraps(f)
|
||||||
|
def decorated(*args, **kwargs):
|
||||||
|
if not is_logged_in():
|
||||||
|
if request.path.startswith("/api/"):
|
||||||
|
return jsonify({"error": "auth required"}), 401
|
||||||
|
return redirect(url_for("auth.login", next=request.path))
|
||||||
|
return f(*args, **kwargs)
|
||||||
|
return decorated
|
||||||
|
|
||||||
|
|
||||||
|
@auth_bp.route("/login", methods=["GET", "POST"])
|
||||||
|
def login():
|
||||||
|
error = None
|
||||||
|
if request.method == "POST":
|
||||||
|
pwd = request.form.get("password", "")
|
||||||
|
cfg = load_config()
|
||||||
|
expected = cfg.get("settings", {}).get("password", "admin")
|
||||||
|
if secrets.compare_digest(pwd, expected):
|
||||||
|
session.clear()
|
||||||
|
session["authed"] = True
|
||||||
|
session.permanent = True
|
||||||
|
nxt = request.args.get("next") or request.form.get("next") or url_for("index")
|
||||||
|
if not nxt.startswith("/"):
|
||||||
|
nxt = url_for("index")
|
||||||
|
return redirect(nxt)
|
||||||
|
error = "Неверный пароль"
|
||||||
|
return render_template("login.html", error=error,
|
||||||
|
next=request.args.get("next", ""))
|
||||||
|
|
||||||
|
|
||||||
|
@auth_bp.route("/logout", methods=["POST", "GET"])
|
||||||
|
def logout():
|
||||||
|
session.clear()
|
||||||
|
return redirect(url_for("auth.login"))
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
[Unit]
|
||||||
|
Description=ButtonTask Web Configurator
|
||||||
|
After=network.target
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=simple
|
||||||
|
User=buttontask
|
||||||
|
Group=buttontask
|
||||||
|
WorkingDirectory=/opt/buttontask/current/webconfig
|
||||||
|
Environment=BUTTONTASK_ROOT=/opt/buttontask
|
||||||
|
Environment=BUTTONTASK_CONFIG=/opt/buttontask/config/config.json
|
||||||
|
Environment=BUTTONTASK_HOST=0.0.0.0
|
||||||
|
ExecStart=/usr/bin/python3 /opt/buttontask/current/webconfig/app.py
|
||||||
|
Restart=on-failure
|
||||||
|
RestartSec=3
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
[Unit]
|
||||||
|
Description=ButtonTask Qt5 UI
|
||||||
|
After=network.target
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=simple
|
||||||
|
User=buttontask
|
||||||
|
Group=buttontask
|
||||||
|
WorkingDirectory=/opt/buttontask/current
|
||||||
|
Environment=BUTTONTASK_ROOT=/opt/buttontask
|
||||||
|
Environment=BUTTONTASK_CONFIG=/opt/buttontask/config/config.json
|
||||||
|
Environment=QT_QPA_PLATFORM=eglfs
|
||||||
|
ExecStart=/opt/buttontask/current/ButtonTask --config /opt/buttontask/config/config.json
|
||||||
|
Restart=on-failure
|
||||||
|
RestartSec=3
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
@@ -0,0 +1,121 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Config storage for the ButtonTask web configurator.
|
||||||
|
|
||||||
|
Reads/writes the same JSON config file consumed by the Qt5 application.
|
||||||
|
Defaults here must stay in sync with ConfigManager::ensureDefaults() in C++.
|
||||||
|
"""
|
||||||
|
import copy
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import tempfile
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
ALLOWED_ICON_EXT = {".png", ".jpg", ".jpeg", ".bmp", ".svg", ".gif"}
|
||||||
|
|
||||||
|
|
||||||
|
def config_path():
|
||||||
|
"""Resolve the active config path.
|
||||||
|
|
||||||
|
Priority:
|
||||||
|
1. BUTTONTASK_CONFIG environment variable.
|
||||||
|
2. <repo>/config/config.json next to this package (dev fallback).
|
||||||
|
"""
|
||||||
|
env = os.environ.get("BUTTONTASK_CONFIG")
|
||||||
|
if env:
|
||||||
|
return Path(env)
|
||||||
|
# webconfig/ -> repo root -> config/config.json
|
||||||
|
return (Path(__file__).resolve().parent.parent / "config" / "config.json")
|
||||||
|
|
||||||
|
|
||||||
|
def _defaults():
|
||||||
|
return {
|
||||||
|
"version": 2,
|
||||||
|
"layout": {"mode": "grid", "columns": 2, "spacing": 10, "showLabels": True},
|
||||||
|
"background": {
|
||||||
|
"type": "gradient",
|
||||||
|
"color1": "#00007b",
|
||||||
|
"color2": "#7b007b",
|
||||||
|
"animated": True,
|
||||||
|
"imagePath": "",
|
||||||
|
},
|
||||||
|
"feedback": {
|
||||||
|
"okColor": "#00cc44",
|
||||||
|
"errorColor": "#cc2200",
|
||||||
|
"okWidth": 6,
|
||||||
|
"errorWidth": 6,
|
||||||
|
"glowRadius": 20,
|
||||||
|
},
|
||||||
|
"settings": {
|
||||||
|
"darkMode": True,
|
||||||
|
"password": "admin",
|
||||||
|
"kioskMode": False,
|
||||||
|
"iconsDir": "./icons",
|
||||||
|
"webPort": 8080,
|
||||||
|
},
|
||||||
|
"buttons": [],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def ensure_defaults(cfg):
|
||||||
|
"""Fill in any missing sections/keys without overwriting existing values."""
|
||||||
|
base = _defaults()
|
||||||
|
if not isinstance(cfg, dict):
|
||||||
|
return base
|
||||||
|
cfg.setdefault("version", base["version"])
|
||||||
|
for section in ("layout", "background", "feedback", "settings"):
|
||||||
|
sec = cfg.setdefault(section, {})
|
||||||
|
if not isinstance(sec, dict):
|
||||||
|
sec = {}
|
||||||
|
cfg[section] = sec
|
||||||
|
for k, v in base[section].items():
|
||||||
|
sec.setdefault(k, v)
|
||||||
|
if not isinstance(cfg.get("buttons"), list):
|
||||||
|
cfg["buttons"] = []
|
||||||
|
return cfg
|
||||||
|
|
||||||
|
|
||||||
|
def load_config():
|
||||||
|
p = config_path()
|
||||||
|
if not p.exists():
|
||||||
|
cfg = ensure_defaults(_defaults())
|
||||||
|
# point iconsDir at a sensible absolute default for the web side
|
||||||
|
cfg["settings"]["iconsDir"] = str((p.parent.parent / "icons"))
|
||||||
|
return cfg
|
||||||
|
with p.open("r", encoding="utf-8") as f:
|
||||||
|
cfg = json.load(f)
|
||||||
|
return ensure_defaults(cfg)
|
||||||
|
|
||||||
|
|
||||||
|
def save_config(cfg):
|
||||||
|
p = config_path()
|
||||||
|
p.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
fd, tmp = tempfile.mkstemp(prefix=".cfg.", suffix=".tmp", dir=str(p.parent))
|
||||||
|
try:
|
||||||
|
with os.fdopen(fd, "w", encoding="utf-8") as f:
|
||||||
|
json.dump(cfg, f, indent=4, ensure_ascii=False)
|
||||||
|
os.replace(tmp, p)
|
||||||
|
except Exception:
|
||||||
|
if os.path.exists(tmp):
|
||||||
|
os.unlink(tmp)
|
||||||
|
raise
|
||||||
|
|
||||||
|
|
||||||
|
def icons_dir():
|
||||||
|
cfg = load_config()
|
||||||
|
d = cfg.get("settings", {}).get("iconsDir") or "./icons"
|
||||||
|
p = Path(d)
|
||||||
|
if not p.is_absolute():
|
||||||
|
p = (config_path().parent.parent / d).resolve()
|
||||||
|
p.mkdir(parents=True, exist_ok=True)
|
||||||
|
return p
|
||||||
|
|
||||||
|
|
||||||
|
def list_icons():
|
||||||
|
return sorted(
|
||||||
|
f.name for f in icons_dir().iterdir()
|
||||||
|
if f.is_file() and f.suffix.lower() in ALLOWED_ICON_EXT
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def default_config():
|
||||||
|
return copy.deepcopy(_defaults())
|
||||||
@@ -0,0 +1,155 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Ethernet network configuration via NetworkManager (nmcli).
|
||||||
|
|
||||||
|
Reading current state is done directly (read-only nmcli). Applying a new
|
||||||
|
configuration is delegated to the privileged helper script `bt-netconfig`
|
||||||
|
(invoked through sudo) so the web service can keep running unprivileged.
|
||||||
|
"""
|
||||||
|
import ipaddress
|
||||||
|
import re
|
||||||
|
import subprocess
|
||||||
|
|
||||||
|
from flask import Blueprint, abort, jsonify, request
|
||||||
|
|
||||||
|
from auth import login_required
|
||||||
|
from paths import scripts_dir
|
||||||
|
|
||||||
|
network_bp = Blueprint("network", __name__)
|
||||||
|
|
||||||
|
_IFACE_RE = re.compile(r"^[A-Za-z0-9_.:-]{1,32}$")
|
||||||
|
|
||||||
|
|
||||||
|
def _run(args, timeout=10):
|
||||||
|
try:
|
||||||
|
out = subprocess.run(args, capture_output=True, text=True, timeout=timeout)
|
||||||
|
return out.returncode, out.stdout, out.stderr
|
||||||
|
except FileNotFoundError:
|
||||||
|
return 127, "", f"{args[0]} not found"
|
||||||
|
except subprocess.TimeoutExpired:
|
||||||
|
return 124, "", "timeout"
|
||||||
|
|
||||||
|
|
||||||
|
def list_ethernet_devices():
|
||||||
|
"""Return [{device, state, connection}] for ethernet interfaces."""
|
||||||
|
rc, out, _ = _run([
|
||||||
|
"nmcli", "-t", "-f", "DEVICE,TYPE,STATE,CONNECTION", "device", "status",
|
||||||
|
])
|
||||||
|
devices = []
|
||||||
|
if rc != 0:
|
||||||
|
return devices
|
||||||
|
for line in out.splitlines():
|
||||||
|
parts = line.split(":")
|
||||||
|
if len(parts) < 4:
|
||||||
|
continue
|
||||||
|
device, dtype, state, connection = parts[0], parts[1], parts[2], parts[3]
|
||||||
|
if dtype != "ethernet":
|
||||||
|
continue
|
||||||
|
devices.append({"device": device, "state": state, "connection": connection})
|
||||||
|
return devices
|
||||||
|
|
||||||
|
|
||||||
|
def device_details(device):
|
||||||
|
"""Return current IP4 config for a device."""
|
||||||
|
rc, out, _ = _run([
|
||||||
|
"nmcli", "-t", "-f",
|
||||||
|
"IP4.ADDRESS,IP4.GATEWAY,IP4.DNS,GENERAL.CONNECTION",
|
||||||
|
"device", "show", device,
|
||||||
|
])
|
||||||
|
info = {"device": device, "addresses": [], "gateway": "", "dns": [],
|
||||||
|
"connection": ""}
|
||||||
|
if rc != 0:
|
||||||
|
return info
|
||||||
|
for line in out.splitlines():
|
||||||
|
if ":" not in line:
|
||||||
|
continue
|
||||||
|
key, _, val = line.partition(":")
|
||||||
|
val = val.strip()
|
||||||
|
if not val:
|
||||||
|
continue
|
||||||
|
if key.startswith("IP4.ADDRESS"):
|
||||||
|
info["addresses"].append(val)
|
||||||
|
elif key == "IP4.GATEWAY":
|
||||||
|
info["gateway"] = val
|
||||||
|
elif key.startswith("IP4.DNS"):
|
||||||
|
info["dns"].append(val)
|
||||||
|
elif key == "GENERAL.CONNECTION":
|
||||||
|
info["connection"] = val
|
||||||
|
return info
|
||||||
|
|
||||||
|
|
||||||
|
def current_state():
|
||||||
|
devices = list_ethernet_devices()
|
||||||
|
for d in devices:
|
||||||
|
d.update({k: v for k, v in device_details(d["device"]).items()
|
||||||
|
if k != "device"})
|
||||||
|
return {"devices": devices}
|
||||||
|
|
||||||
|
|
||||||
|
def _valid_ip(value):
|
||||||
|
try:
|
||||||
|
ipaddress.ip_address(value)
|
||||||
|
return True
|
||||||
|
except ValueError:
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_payload(data):
|
||||||
|
iface = (data.get("iface") or "").strip()
|
||||||
|
if not _IFACE_RE.match(iface):
|
||||||
|
return None, "invalid iface"
|
||||||
|
mode = (data.get("mode") or "dhcp").strip().lower()
|
||||||
|
if mode not in ("dhcp", "static"):
|
||||||
|
return None, "mode must be dhcp or static"
|
||||||
|
payload = {"iface": iface, "mode": mode}
|
||||||
|
if mode == "static":
|
||||||
|
address = (data.get("address") or "").strip()
|
||||||
|
if not _valid_ip(address):
|
||||||
|
return None, "invalid address"
|
||||||
|
try:
|
||||||
|
prefix = int(data.get("prefix", 24))
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return None, "invalid prefix"
|
||||||
|
if not (1 <= prefix <= 32):
|
||||||
|
return None, "prefix must be 1..32"
|
||||||
|
gateway = (data.get("gateway") or "").strip()
|
||||||
|
if gateway and not _valid_ip(gateway):
|
||||||
|
return None, "invalid gateway"
|
||||||
|
dns = [d.strip() for d in (data.get("dns") or "").replace(";", ",").split(",")
|
||||||
|
if d.strip()]
|
||||||
|
for d in dns:
|
||||||
|
if not _valid_ip(d):
|
||||||
|
return None, f"invalid dns: {d}"
|
||||||
|
payload.update({
|
||||||
|
"address": address,
|
||||||
|
"prefix": prefix,
|
||||||
|
"gateway": gateway,
|
||||||
|
"dns": ",".join(dns),
|
||||||
|
})
|
||||||
|
return payload, None
|
||||||
|
|
||||||
|
|
||||||
|
@network_bp.route("/api/network", methods=["GET"])
|
||||||
|
@login_required
|
||||||
|
def api_get_network():
|
||||||
|
return jsonify(current_state())
|
||||||
|
|
||||||
|
|
||||||
|
@network_bp.route("/api/network", methods=["PUT"])
|
||||||
|
@login_required
|
||||||
|
def api_set_network():
|
||||||
|
data = request.get_json(force=True, silent=True) or {}
|
||||||
|
payload, err = _validate_payload(data)
|
||||||
|
if err:
|
||||||
|
abort(400, err)
|
||||||
|
|
||||||
|
script = str(scripts_dir() / "bt-netconfig")
|
||||||
|
args = ["sudo", "-n", script, payload["iface"], payload["mode"]]
|
||||||
|
if payload["mode"] == "static":
|
||||||
|
args += [payload["address"], str(payload["prefix"]),
|
||||||
|
payload.get("gateway", ""), payload.get("dns", "")]
|
||||||
|
|
||||||
|
rc, out, err_out = _run(args, timeout=30)
|
||||||
|
if rc != 0:
|
||||||
|
return jsonify({"ok": False, "error": err_out.strip() or out.strip()
|
||||||
|
or f"exit {rc}"}), 500
|
||||||
|
return jsonify({"ok": True, "output": out.strip()})
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Filesystem layout helpers shared by the web configurator and updater.
|
||||||
|
|
||||||
|
On the target device the layout is:
|
||||||
|
|
||||||
|
/opt/buttontask/
|
||||||
|
versions/<ver>/ (ButtonTask + webconfig/)
|
||||||
|
current -> versions/<ver>
|
||||||
|
config/ icons/ (shared, never replaced)
|
||||||
|
run/ (heartbeat, update-status.json)
|
||||||
|
scripts/ (privileged helpers, see scripts/)
|
||||||
|
"""
|
||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
||||||
|
def install_root():
|
||||||
|
"""Resolve the ButtonTask install root (the directory holding
|
||||||
|
versions/, current, config/, run/)."""
|
||||||
|
env = os.environ.get("BUTTONTASK_ROOT")
|
||||||
|
if env:
|
||||||
|
return Path(env)
|
||||||
|
return Path("/opt/buttontask")
|
||||||
|
|
||||||
|
|
||||||
|
def run_dir():
|
||||||
|
p = install_root() / "run"
|
||||||
|
try:
|
||||||
|
p.mkdir(parents=True, exist_ok=True)
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
return p
|
||||||
|
|
||||||
|
|
||||||
|
def versions_dir():
|
||||||
|
return install_root() / "versions"
|
||||||
|
|
||||||
|
|
||||||
|
def current_link():
|
||||||
|
return install_root() / "current"
|
||||||
|
|
||||||
|
|
||||||
|
def scripts_dir():
|
||||||
|
"""Directory with the privileged helper scripts.
|
||||||
|
|
||||||
|
Prefer the installed copy under <root>/scripts, fall back to the copy
|
||||||
|
that ships next to this package (dev/source tree)."""
|
||||||
|
installed = install_root() / "scripts"
|
||||||
|
if installed.is_dir():
|
||||||
|
return installed
|
||||||
|
return Path(__file__).resolve().parent / "scripts"
|
||||||
|
|
||||||
|
|
||||||
|
QT_HEARTBEAT = "qt.alive"
|
||||||
|
UPDATE_STATUS = "update-status.json"
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
Flask>=2.0,<4.0
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
#
|
||||||
|
# bt-netconfig - apply Ethernet configuration via NetworkManager.
|
||||||
|
#
|
||||||
|
# Usage:
|
||||||
|
# bt-netconfig <iface> dhcp
|
||||||
|
# bt-netconfig <iface> static <address> <prefix> [gateway] [dns_csv]
|
||||||
|
#
|
||||||
|
# Intended to be invoked through sudo by the (unprivileged) web configurator.
|
||||||
|
# Keep this script tightly scoped: it only touches the connection bound to the
|
||||||
|
# requested ethernet interface.
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
err() { echo "bt-netconfig: $*" >&2; exit 1; }
|
||||||
|
|
||||||
|
[ "$#" -ge 2 ] || err "usage: bt-netconfig <iface> <dhcp|static> ..."
|
||||||
|
|
||||||
|
IFACE="$1"
|
||||||
|
MODE="$2"
|
||||||
|
|
||||||
|
# Validate interface name (defence in depth; the web side validates too).
|
||||||
|
[[ "$IFACE" =~ ^[A-Za-z0-9_.:-]{1,32}$ ]] || err "invalid interface name"
|
||||||
|
|
||||||
|
command -v nmcli >/dev/null 2>&1 || err "nmcli not found"
|
||||||
|
|
||||||
|
# Resolve (or create) a connection profile bound to this interface.
|
||||||
|
CON="$(nmcli -t -f GENERAL.CONNECTION device show "$IFACE" 2>/dev/null \
|
||||||
|
| sed 's/^GENERAL.CONNECTION://')"
|
||||||
|
|
||||||
|
if [ -z "$CON" ] || [ "$CON" = "--" ]; then
|
||||||
|
CON="buttontask-$IFACE"
|
||||||
|
if ! nmcli -t -f NAME connection show | grep -Fxq "$CON"; then
|
||||||
|
nmcli connection add type ethernet ifname "$IFACE" con-name "$CON" >/dev/null
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
case "$MODE" in
|
||||||
|
dhcp)
|
||||||
|
nmcli connection modify "$CON" \
|
||||||
|
ipv4.method auto \
|
||||||
|
ipv4.addresses "" \
|
||||||
|
ipv4.gateway "" \
|
||||||
|
ipv4.dns ""
|
||||||
|
;;
|
||||||
|
static)
|
||||||
|
[ "$#" -ge 4 ] || err "static requires <address> <prefix>"
|
||||||
|
ADDR="$3"
|
||||||
|
PREFIX="$4"
|
||||||
|
GW="${5:-}"
|
||||||
|
DNS="${6:-}"
|
||||||
|
[[ "$PREFIX" =~ ^[0-9]{1,2}$ ]] && [ "$PREFIX" -ge 1 ] && [ "$PREFIX" -le 32 ] \
|
||||||
|
|| err "invalid prefix"
|
||||||
|
nmcli connection modify "$CON" \
|
||||||
|
ipv4.method manual \
|
||||||
|
ipv4.addresses "${ADDR}/${PREFIX}" \
|
||||||
|
ipv4.gateway "$GW" \
|
||||||
|
ipv4.dns "$DNS"
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
err "mode must be dhcp or static"
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
# Re-apply the connection so changes take effect.
|
||||||
|
nmcli connection up "$CON" >/dev/null
|
||||||
|
echo "applied $MODE on $IFACE ($CON)"
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
#
|
||||||
|
# bt-service - restart/start/stop the ButtonTask systemd units.
|
||||||
|
# Invoked through sudo by the web configurator (tightly scoped).
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
ACTION="${1:-}"
|
||||||
|
case "$ACTION" in
|
||||||
|
start|stop|restart)
|
||||||
|
systemctl "$ACTION" buttontask-web buttontask
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
echo "usage: bt-service {start|stop|restart}" >&2
|
||||||
|
exit 2
|
||||||
|
;;
|
||||||
|
esac
|
||||||
@@ -0,0 +1,152 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
#
|
||||||
|
# bt-update - OTA update with automatic rollback for ButtonTask.
|
||||||
|
#
|
||||||
|
# Usage:
|
||||||
|
# bt-update stage <package.tar.gz> # deploy + health-check + commit/rollback
|
||||||
|
# bt-update rollback # revert to the previous version
|
||||||
|
#
|
||||||
|
# Layout (see paths.py):
|
||||||
|
# $ROOT/versions/<ver>/ deployed versions (ButtonTask + webconfig/)
|
||||||
|
# $ROOT/current -> versions/<ver>
|
||||||
|
# $ROOT/run/update-status.json progress for the web UI
|
||||||
|
# $ROOT/run/qt.alive heartbeat written by the Qt UI
|
||||||
|
# $ROOT/.previous path of the previously active version
|
||||||
|
#
|
||||||
|
# Designed to be launched detached (setsid) via sudo by the web service, so it
|
||||||
|
# survives the service restart performed mid-update.
|
||||||
|
set -uo pipefail
|
||||||
|
|
||||||
|
ROOT="${BUTTONTASK_ROOT:-/opt/buttontask}"
|
||||||
|
VERSIONS="$ROOT/versions"
|
||||||
|
CURRENT="$ROOT/current"
|
||||||
|
RUN="$ROOT/run"
|
||||||
|
STATUS="$RUN/update-status.json"
|
||||||
|
HEARTBEAT="$RUN/qt.alive"
|
||||||
|
PREV_MARK="$ROOT/.previous"
|
||||||
|
HEALTH_TIMEOUT="${BUTTONTASK_HEALTH_TIMEOUT:-30}"
|
||||||
|
|
||||||
|
mkdir -p "$RUN" "$VERSIONS"
|
||||||
|
|
||||||
|
write_status() {
|
||||||
|
local state="$1"; local msg="${2:-}"
|
||||||
|
msg="${msg//\"/}"
|
||||||
|
printf '{"state":"%s","message":"%s","ts":%s}\n' "$state" "$msg" "$(date +%s)" > "$STATUS"
|
||||||
|
}
|
||||||
|
|
||||||
|
web_port() {
|
||||||
|
python3 - <<'PY' 2>/dev/null || echo 8080
|
||||||
|
import json, os
|
||||||
|
root = os.environ.get("BUTTONTASK_ROOT", "/opt/buttontask")
|
||||||
|
try:
|
||||||
|
with open(os.path.join(root, "config", "config.json")) as f:
|
||||||
|
print(int(json.load(f).get("settings", {}).get("webPort", 8080)))
|
||||||
|
except Exception:
|
||||||
|
print(8080)
|
||||||
|
PY
|
||||||
|
}
|
||||||
|
|
||||||
|
restart_services() {
|
||||||
|
systemctl restart buttontask-web buttontask 2>/dev/null || true
|
||||||
|
}
|
||||||
|
|
||||||
|
# Returns 0 when the freshly started version looks healthy.
|
||||||
|
health_check() {
|
||||||
|
local port deadline now hb_age
|
||||||
|
port="$(web_port)"
|
||||||
|
deadline=$(( $(date +%s) + HEALTH_TIMEOUT ))
|
||||||
|
while [ "$(date +%s)" -lt "$deadline" ]; do
|
||||||
|
# 1) web answers /healthz
|
||||||
|
if curl -fsS "http://127.0.0.1:${port}/healthz" >/dev/null 2>&1; then
|
||||||
|
# 2) Qt UI heartbeat is fresh (file touched in the last 20s)
|
||||||
|
if [ -f "$HEARTBEAT" ]; then
|
||||||
|
now=$(date +%s)
|
||||||
|
hb_age=$(( now - $(stat -c %Y "$HEARTBEAT" 2>/dev/null || echo 0) ))
|
||||||
|
if [ "$hb_age" -le 20 ]; then
|
||||||
|
# 3) Qt service is active
|
||||||
|
if systemctl is-active --quiet buttontask 2>/dev/null; then
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
sleep 2
|
||||||
|
done
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
|
switch_to() {
|
||||||
|
# Atomically repoint the current symlink.
|
||||||
|
ln -sfn "$1" "$CURRENT"
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd_stage() {
|
||||||
|
local pkg="$1"
|
||||||
|
[ -f "$pkg" ] || { write_status error "package not found"; exit 1; }
|
||||||
|
|
||||||
|
write_status staging "распаковка"
|
||||||
|
local tmp ver dest
|
||||||
|
tmp="$(mktemp -d "$VERSIONS/.stage.XXXXXX")"
|
||||||
|
if ! tar -xzf "$pkg" -C "$tmp"; then
|
||||||
|
rm -rf "$tmp"; write_status error "bad archive"; exit 1
|
||||||
|
fi
|
||||||
|
# Some packages wrap content in a single top dir; flatten if so.
|
||||||
|
if [ ! -f "$tmp/manifest.json" ]; then
|
||||||
|
local inner
|
||||||
|
inner="$(find "$tmp" -maxdepth 2 -name manifest.json | head -n1)"
|
||||||
|
[ -n "$inner" ] && tmp="$(dirname "$inner")"
|
||||||
|
fi
|
||||||
|
[ -f "$tmp/manifest.json" ] || { rm -rf "$tmp"; write_status error "manifest.json missing"; exit 1; }
|
||||||
|
[ -x "$tmp/ButtonTask" ] || chmod +x "$tmp/ButtonTask" 2>/dev/null || true
|
||||||
|
|
||||||
|
ver="$(python3 -c "import json,sys;print(json.load(open('$tmp/manifest.json')).get('version','unknown'))" 2>/dev/null || echo unknown)"
|
||||||
|
dest="$VERSIONS/$ver"
|
||||||
|
|
||||||
|
# Remember the current version for rollback.
|
||||||
|
if [ -L "$CURRENT" ]; then
|
||||||
|
readlink -f "$CURRENT" > "$PREV_MARK"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Avoid clobbering the running version directory.
|
||||||
|
if [ "$(readlink -f "$CURRENT" 2>/dev/null)" = "$(readlink -f "$dest" 2>/dev/null)" ]; then
|
||||||
|
dest="${dest}-$(date +%s)"
|
||||||
|
fi
|
||||||
|
rm -rf "$dest"
|
||||||
|
mv "$tmp" "$dest"
|
||||||
|
chown -R buttontask:buttontask "$dest" 2>/dev/null || true
|
||||||
|
rm -f "$pkg"
|
||||||
|
|
||||||
|
write_status switching "$ver"
|
||||||
|
switch_to "$dest"
|
||||||
|
restart_services
|
||||||
|
|
||||||
|
write_status health_check "$ver"
|
||||||
|
if health_check; then
|
||||||
|
write_status success "$ver"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Rollback.
|
||||||
|
if [ -s "$PREV_MARK" ]; then
|
||||||
|
switch_to "$(cat "$PREV_MARK")"
|
||||||
|
restart_services
|
||||||
|
write_status rolledback "запуск $ver не удался"
|
||||||
|
else
|
||||||
|
write_status error "health-check failed, no previous version"
|
||||||
|
fi
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd_rollback() {
|
||||||
|
[ -s "$PREV_MARK" ] || { write_status error "no previous version"; exit 1; }
|
||||||
|
write_status switching "rollback"
|
||||||
|
switch_to "$(cat "$PREV_MARK")"
|
||||||
|
restart_services
|
||||||
|
write_status rollback_done "откат выполнен"
|
||||||
|
}
|
||||||
|
|
||||||
|
case "${1:-}" in
|
||||||
|
stage) [ "$#" -ge 2 ] || { echo "usage: bt-update stage <pkg>"; exit 2; }; cmd_stage "$2" ;;
|
||||||
|
rollback) cmd_rollback ;;
|
||||||
|
*) echo "usage: bt-update {stage <pkg>|rollback}"; exit 2 ;;
|
||||||
|
esac
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
# ButtonTask: allow the unprivileged web configurator to run only the three
|
||||||
|
# tightly-scoped helper scripts as root, without a password.
|
||||||
|
#
|
||||||
|
# Install:
|
||||||
|
# sudo install -m 0440 buttontask.sudoers /etc/sudoers.d/buttontask
|
||||||
|
# sudo visudo -cf /etc/sudoers.d/buttontask # validate
|
||||||
|
#
|
||||||
|
# The scripts themselves must be root-owned and not writable by others:
|
||||||
|
# sudo chown root:root /opt/buttontask/scripts/bt-*
|
||||||
|
# sudo chmod 0755 /opt/buttontask/scripts/bt-*
|
||||||
|
|
||||||
|
buttontask ALL=(root) NOPASSWD: /opt/buttontask/scripts/bt-netconfig, /opt/buttontask/scripts/bt-update, /opt/buttontask/scripts/bt-service
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
#
|
||||||
|
# make-package.sh - build an OTA update package for ButtonTask.
|
||||||
|
#
|
||||||
|
# Produces dist/buttontask-<version>.tar.gz containing:
|
||||||
|
# ButtonTask (the compiled binary)
|
||||||
|
# webconfig/ (the web configurator + scripts)
|
||||||
|
# manifest.json ({"version": ..., "created": ...})
|
||||||
|
# and prints the package's sha256 (paste it into the web UI for integrity).
|
||||||
|
#
|
||||||
|
# Usage:
|
||||||
|
# make-package.sh <version> [build-dir] [out-dir]
|
||||||
|
# Example:
|
||||||
|
# ./webconfig/scripts/make-package.sh 2.1 build dist
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
VERSION="${1:?usage: make-package.sh <version> [build-dir] [out-dir]}"
|
||||||
|
BUILD_DIR="${2:-build}"
|
||||||
|
OUT_DIR="${3:-dist}"
|
||||||
|
|
||||||
|
# Resolve repo root (two levels up from this script).
|
||||||
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
REPO_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)"
|
||||||
|
|
||||||
|
BIN="$REPO_ROOT/$BUILD_DIR/ButtonTask"
|
||||||
|
[ -x "$BIN" ] || { echo "binary not found: $BIN (build first)"; exit 1; }
|
||||||
|
|
||||||
|
STAGE="$(mktemp -d)"
|
||||||
|
trap 'rm -rf "$STAGE"' EXIT
|
||||||
|
|
||||||
|
cp "$BIN" "$STAGE/ButtonTask"
|
||||||
|
cp -r "$REPO_ROOT/webconfig" "$STAGE/webconfig"
|
||||||
|
# Drop dev artefacts from the packaged webconfig.
|
||||||
|
rm -rf "$STAGE/webconfig/__pycache__" "$STAGE/webconfig/.webconfig.secret"
|
||||||
|
|
||||||
|
cat > "$STAGE/manifest.json" <<EOF
|
||||||
|
{
|
||||||
|
"version": "$VERSION",
|
||||||
|
"created": "$(date -u +%Y-%m-%dT%H:%M:%SZ)"
|
||||||
|
}
|
||||||
|
EOF
|
||||||
|
|
||||||
|
mkdir -p "$REPO_ROOT/$OUT_DIR"
|
||||||
|
PKG="$REPO_ROOT/$OUT_DIR/buttontask-$VERSION.tar.gz"
|
||||||
|
tar -czf "$PKG" -C "$STAGE" .
|
||||||
|
|
||||||
|
echo "package: $PKG"
|
||||||
|
if command -v sha256sum >/dev/null 2>&1; then
|
||||||
|
echo "sha256: $(sha256sum "$PKG" | awk '{print $1}')"
|
||||||
|
fi
|
||||||
@@ -0,0 +1,336 @@
|
|||||||
|
// ButtonTask v2 — web configurator client logic
|
||||||
|
|
||||||
|
const $ = (sel) => document.querySelector(sel);
|
||||||
|
const $$ = (sel) => Array.from(document.querySelectorAll(sel));
|
||||||
|
|
||||||
|
async function api(method, path, body) {
|
||||||
|
const opts = { method, headers: {} };
|
||||||
|
if (body !== undefined) {
|
||||||
|
opts.headers["Content-Type"] = "application/json";
|
||||||
|
opts.body = JSON.stringify(body);
|
||||||
|
}
|
||||||
|
const r = await fetch(path, opts);
|
||||||
|
if (r.status === 401) {
|
||||||
|
window.location.href = "/login?next=" + encodeURIComponent(window.location.pathname);
|
||||||
|
throw new Error("auth required");
|
||||||
|
}
|
||||||
|
if (!r.ok) {
|
||||||
|
let msg = `${method} ${path} -> ${r.status}`;
|
||||||
|
try { const j = await r.json(); if (j.error) msg = j.error; } catch (e) {}
|
||||||
|
throw new Error(msg);
|
||||||
|
}
|
||||||
|
return r.headers.get("content-type")?.includes("application/json")
|
||||||
|
? r.json() : r.text();
|
||||||
|
}
|
||||||
|
|
||||||
|
function flash(text, isError) {
|
||||||
|
const t = document.createElement("div");
|
||||||
|
t.className = "toast" + (isError ? " error" : "");
|
||||||
|
t.textContent = text;
|
||||||
|
document.body.appendChild(t);
|
||||||
|
setTimeout(() => t.remove(), 2800);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- Tabs ----------
|
||||||
|
$$(".tab").forEach(tab => {
|
||||||
|
tab.addEventListener("click", () => {
|
||||||
|
$$(".tab").forEach(t => t.classList.remove("active"));
|
||||||
|
$$(".panel").forEach(p => p.classList.remove("active"));
|
||||||
|
tab.classList.add("active");
|
||||||
|
$("#tab-" + tab.dataset.tab).classList.add("active");
|
||||||
|
if (tab.dataset.tab === "network") loadNetwork();
|
||||||
|
if (tab.dataset.tab === "update") loadUpdateStatus();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------- Layout ----------
|
||||||
|
$("#save-layout")?.addEventListener("click", async () => {
|
||||||
|
try {
|
||||||
|
await api("PUT", "/api/layout", {
|
||||||
|
mode: $("#layout-mode").value,
|
||||||
|
columns: parseInt($("#layout-columns").value, 10),
|
||||||
|
spacing: parseInt($("#layout-spacing").value, 10),
|
||||||
|
showLabels: $("#layout-show-labels").checked,
|
||||||
|
});
|
||||||
|
flash("Расположение сохранено");
|
||||||
|
} catch (e) { flash(e.message, true); }
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------- Background ----------
|
||||||
|
$("#save-bg")?.addEventListener("click", async () => {
|
||||||
|
try {
|
||||||
|
await api("PUT", "/api/background", {
|
||||||
|
type: $("#bg-type").value,
|
||||||
|
color1: $("#bg-c1").value,
|
||||||
|
color2: $("#bg-c2").value,
|
||||||
|
animated: $("#bg-animated").checked,
|
||||||
|
imagePath: $("#bg-image").value,
|
||||||
|
});
|
||||||
|
flash("Фон сохранён");
|
||||||
|
} catch (e) { flash(e.message, true); }
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------- Feedback (borders) ----------
|
||||||
|
function syncFeedbackPreview() {
|
||||||
|
const ok = $("#fb-preview-ok"), err = $("#fb-preview-error");
|
||||||
|
if (!ok) return;
|
||||||
|
const okColor = $("#fb-ok-color").value;
|
||||||
|
const errColor = $("#fb-error-color").value;
|
||||||
|
const okW = $("#fb-ok-width").value;
|
||||||
|
const errW = $("#fb-error-width").value;
|
||||||
|
const glow = $("#fb-glow").value;
|
||||||
|
$("#fb-ok-width-val").textContent = okW;
|
||||||
|
$("#fb-error-width-val").textContent = errW;
|
||||||
|
$("#fb-glow-val").textContent = glow;
|
||||||
|
ok.style.boxShadow = `0 0 ${glow}px ${Math.round(glow/2)}px ${okColor}`;
|
||||||
|
ok.style.border = `${okW}px solid ${okColor}`;
|
||||||
|
err.style.boxShadow = `0 0 ${glow}px ${Math.round(glow/2)}px ${errColor}`;
|
||||||
|
err.style.border = `${errW}px solid ${errColor}`;
|
||||||
|
}
|
||||||
|
["fb-ok-color", "fb-error-color", "fb-ok-width", "fb-error-width", "fb-glow"]
|
||||||
|
.forEach(id => $("#" + id)?.addEventListener("input", syncFeedbackPreview));
|
||||||
|
syncFeedbackPreview();
|
||||||
|
|
||||||
|
$("#save-feedback")?.addEventListener("click", async () => {
|
||||||
|
try {
|
||||||
|
await api("PUT", "/api/feedback", {
|
||||||
|
okColor: $("#fb-ok-color").value,
|
||||||
|
errorColor: $("#fb-error-color").value,
|
||||||
|
okWidth: parseInt($("#fb-ok-width").value, 10),
|
||||||
|
errorWidth: parseInt($("#fb-error-width").value, 10),
|
||||||
|
glowRadius: parseInt($("#fb-glow").value, 10),
|
||||||
|
});
|
||||||
|
flash("Обводка сохранена");
|
||||||
|
} catch (e) { flash(e.message, true); }
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------- Icons ----------
|
||||||
|
$("#upload-icon")?.addEventListener("click", async () => {
|
||||||
|
const file = $("#icon-file").files[0];
|
||||||
|
if (!file) { flash("Выберите файл", true); return; }
|
||||||
|
const fd = new FormData();
|
||||||
|
fd.append("file", file);
|
||||||
|
const r = await fetch("/api/icons", { method: "POST", body: fd });
|
||||||
|
if (!r.ok) { flash("Ошибка загрузки", true); return; }
|
||||||
|
location.reload();
|
||||||
|
});
|
||||||
|
|
||||||
|
$$(".icon-tile .del").forEach(btn => {
|
||||||
|
btn.addEventListener("click", async (e) => {
|
||||||
|
const name = e.target.dataset.name;
|
||||||
|
if (!confirm(`Удалить ${name}?`)) return;
|
||||||
|
await api("DELETE", `/api/icons/${encodeURIComponent(name)}`);
|
||||||
|
location.reload();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------- Password ----------
|
||||||
|
$("#save-password")?.addEventListener("click", async () => {
|
||||||
|
const pwd = $("#new-password").value;
|
||||||
|
if (!pwd) { flash("Введите пароль", true); return; }
|
||||||
|
await api("PUT", "/api/settings", { password: pwd });
|
||||||
|
flash("Пароль сохранён. Действует со следующего входа.");
|
||||||
|
$("#new-password").value = "";
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------- Buttons drag-drop ----------
|
||||||
|
const list = $("#buttons-list");
|
||||||
|
if (list && window.Sortable) {
|
||||||
|
new Sortable(list, {
|
||||||
|
handle: ".handle",
|
||||||
|
animation: 150,
|
||||||
|
onEnd: async () => {
|
||||||
|
const order = Array.from(list.children).map(li => li.dataset.id);
|
||||||
|
await api("POST", "/api/buttons/reorder", { order });
|
||||||
|
flash("Порядок сохранён");
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- Buttons CRUD ----------
|
||||||
|
const dlg = $("#btn-dialog");
|
||||||
|
const form = $("#btn-form");
|
||||||
|
|
||||||
|
function openDialogFor(btn) {
|
||||||
|
$("#btn-dialog-title").textContent = btn ? "Редактирование" : "Новая кнопка";
|
||||||
|
$("#b-id").value = btn?.id || "";
|
||||||
|
$("#b-label").value = btn?.label || "";
|
||||||
|
$("#b-icon").value = btn?.iconPath || "";
|
||||||
|
$("#b-type").value = btn?.action?.type || "http_get";
|
||||||
|
$("#b-url").value = btn?.action?.url || "";
|
||||||
|
$("#b-success").value = btn?.feedback?.successText || "";
|
||||||
|
$("#b-error").value = btn?.feedback?.errorText || "Ошибка";
|
||||||
|
$("#b-color").value = btn?.color || "#7b007b";
|
||||||
|
dlg.showModal();
|
||||||
|
}
|
||||||
|
|
||||||
|
$("#add-btn")?.addEventListener("click", () => openDialogFor(null));
|
||||||
|
|
||||||
|
$$(".buttons-list .edit").forEach(btn => {
|
||||||
|
btn.addEventListener("click", async (e) => {
|
||||||
|
const id = e.target.dataset.id;
|
||||||
|
const cfg = await api("GET", "/api/config");
|
||||||
|
const b = cfg.buttons.find(x => x.id === id);
|
||||||
|
if (b) openDialogFor(b);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
$$(".buttons-list .del").forEach(btn => {
|
||||||
|
btn.addEventListener("click", async (e) => {
|
||||||
|
const id = e.target.dataset.id;
|
||||||
|
if (!confirm("Удалить кнопку?")) return;
|
||||||
|
await api("DELETE", `/api/buttons/${encodeURIComponent(id)}`);
|
||||||
|
location.reload();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
form?.addEventListener("submit", async (e) => {
|
||||||
|
if (e.submitter && e.submitter.value === "cancel") return;
|
||||||
|
e.preventDefault();
|
||||||
|
const id = $("#b-id").value;
|
||||||
|
const payload = {
|
||||||
|
label: $("#b-label").value,
|
||||||
|
iconPath: $("#b-icon").value,
|
||||||
|
actionType: $("#b-type").value,
|
||||||
|
url: $("#b-url").value,
|
||||||
|
successText: $("#b-success").value,
|
||||||
|
errorText: $("#b-error").value,
|
||||||
|
color: $("#b-color").value,
|
||||||
|
};
|
||||||
|
if (id) await api("PUT", `/api/buttons/${encodeURIComponent(id)}`, payload);
|
||||||
|
else await api("POST", "/api/buttons", payload);
|
||||||
|
dlg.close();
|
||||||
|
location.reload();
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------- Network ----------
|
||||||
|
function toggleStatic() {
|
||||||
|
const wrap = $("#net-mode")?.closest(".grid-form");
|
||||||
|
if (!wrap) return;
|
||||||
|
wrap.classList.toggle("show-static", $("#net-mode").value === "static");
|
||||||
|
}
|
||||||
|
$("#net-mode")?.addEventListener("change", toggleStatic);
|
||||||
|
|
||||||
|
async function loadNetwork() {
|
||||||
|
const statusEl = $("#net-status");
|
||||||
|
const ifaceSel = $("#net-iface");
|
||||||
|
if (!statusEl) return;
|
||||||
|
statusEl.textContent = "Загрузка состояния…";
|
||||||
|
try {
|
||||||
|
const data = await api("GET", "/api/network");
|
||||||
|
const devs = data.devices || [];
|
||||||
|
if (!devs.length) {
|
||||||
|
statusEl.innerHTML = "<p class='muted'>Ethernet-интерфейсы не найдены (или nmcli недоступен).</p>";
|
||||||
|
} else {
|
||||||
|
statusEl.innerHTML = devs.map(d => {
|
||||||
|
const up = (d.state || "").includes("connected") && !(d.state || "").includes("dis");
|
||||||
|
const badge = up ? "<span class='badge up'>up</span>" : "<span class='badge down'>down</span>";
|
||||||
|
const addr = (d.addresses || []).join(", ") || "—";
|
||||||
|
return `<div class="net-device">
|
||||||
|
<div class="name">${d.device} ${badge}</div>
|
||||||
|
<div class="detail">Соединение: ${d.connection || "—"}</div>
|
||||||
|
<div class="detail">IP: ${addr}</div>
|
||||||
|
<div class="detail">Шлюз: ${d.gateway || "—"} · DNS: ${(d.dns||[]).join(", ") || "—"}</div>
|
||||||
|
</div>`;
|
||||||
|
}).join("");
|
||||||
|
}
|
||||||
|
if (ifaceSel) {
|
||||||
|
const prev = ifaceSel.value;
|
||||||
|
ifaceSel.innerHTML = devs.map(d => `<option value="${d.device}">${d.device}</option>`).join("");
|
||||||
|
if (prev) ifaceSel.value = prev;
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
statusEl.innerHTML = `<p class='warn'>${e.message}</p>`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$("#net-refresh")?.addEventListener("click", loadNetwork);
|
||||||
|
|
||||||
|
$("#net-apply")?.addEventListener("click", async () => {
|
||||||
|
const mode = $("#net-mode").value;
|
||||||
|
const payload = { iface: $("#net-iface").value, mode };
|
||||||
|
if (mode === "static") {
|
||||||
|
payload.address = $("#net-address").value.trim();
|
||||||
|
payload.prefix = parseInt($("#net-prefix").value, 10);
|
||||||
|
payload.gateway = $("#net-gateway").value.trim();
|
||||||
|
payload.dns = $("#net-dns").value.trim();
|
||||||
|
}
|
||||||
|
if (!payload.iface) { flash("Выберите интерфейс", true); return; }
|
||||||
|
if (!confirm("Применить сетевые настройки? Соединение может прерваться.")) return;
|
||||||
|
try {
|
||||||
|
await api("PUT", "/api/network", payload);
|
||||||
|
flash("Сетевые настройки применены");
|
||||||
|
setTimeout(loadNetwork, 1500);
|
||||||
|
} catch (e) { flash(e.message, true); }
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------- Update / OTA ----------
|
||||||
|
let updatePollTimer = null;
|
||||||
|
|
||||||
|
function renderUpdateStatus(s) {
|
||||||
|
const el = $("#update-status");
|
||||||
|
if (!el) return;
|
||||||
|
const st = (s.status && s.status.state) || "idle";
|
||||||
|
const map = {
|
||||||
|
idle: ["Статус: готово к обновлению", ""],
|
||||||
|
staging: ["Распаковка пакета…", "busy"],
|
||||||
|
switching: ["Переключение версии…", "busy"],
|
||||||
|
health_check: ["Проверка работоспособности…", "busy"],
|
||||||
|
success: ["Обновление успешно применено", "ok"],
|
||||||
|
rolledback: ["Запуск новой версии не удался — выполнен откат", "fail"],
|
||||||
|
rollback_done: ["Откат выполнен", "ok"],
|
||||||
|
error: ["Ошибка обновления", "fail"],
|
||||||
|
};
|
||||||
|
const [text, cls] = map[st] || [`Статус: ${st}`, ""];
|
||||||
|
el.className = "update-status " + cls;
|
||||||
|
el.textContent = (s.status && s.status.message) ? `${text} — ${s.status.message}` : text;
|
||||||
|
if (s.current) {
|
||||||
|
$("#update-version").textContent = s.current.version || "—";
|
||||||
|
$("#update-path").textContent = s.current.path || "—";
|
||||||
|
}
|
||||||
|
const busy = ["staging", "switching", "health_check"].includes(st);
|
||||||
|
if (busy && !updatePollTimer) {
|
||||||
|
updatePollTimer = setInterval(loadUpdateStatus, 2000);
|
||||||
|
} else if (!busy && updatePollTimer) {
|
||||||
|
clearInterval(updatePollTimer); updatePollTimer = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadUpdateStatus() {
|
||||||
|
try {
|
||||||
|
const s = await api("GET", "/api/update/status");
|
||||||
|
renderUpdateStatus(s);
|
||||||
|
} catch (e) {
|
||||||
|
const el = $("#update-status");
|
||||||
|
if (el) { el.className = "update-status fail"; el.textContent = e.message; }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$("#update-upload")?.addEventListener("click", async () => {
|
||||||
|
const file = $("#update-file").files[0];
|
||||||
|
if (!file) { flash("Выберите пакет .tar.gz", true); return; }
|
||||||
|
if (!confirm("Загрузить и применить обновление? Сервис будет перезапущен.")) return;
|
||||||
|
const fd = new FormData();
|
||||||
|
fd.append("file", file);
|
||||||
|
try {
|
||||||
|
const r = await fetch("/api/update", { method: "POST", body: fd });
|
||||||
|
if (!r.ok) {
|
||||||
|
let msg = "Ошибка загрузки";
|
||||||
|
try { const j = await r.json(); if (j.error) msg = j.error; } catch (e) {}
|
||||||
|
throw new Error(msg);
|
||||||
|
}
|
||||||
|
flash("Обновление запущено");
|
||||||
|
setTimeout(loadUpdateStatus, 2000);
|
||||||
|
} catch (e) { flash(e.message, true); }
|
||||||
|
});
|
||||||
|
|
||||||
|
$("#update-rollback")?.addEventListener("click", async () => {
|
||||||
|
if (!confirm("Откатить на предыдущую версию?")) return;
|
||||||
|
try {
|
||||||
|
await api("POST", "/api/update/rollback");
|
||||||
|
flash("Откат запущен");
|
||||||
|
setTimeout(loadUpdateStatus, 2000);
|
||||||
|
} catch (e) { flash(e.message, true); }
|
||||||
|
});
|
||||||
|
|
||||||
|
toggleStatic();
|
||||||
@@ -0,0 +1,281 @@
|
|||||||
|
:root {
|
||||||
|
--bg: #14171d;
|
||||||
|
--bg-elev: #1c2029;
|
||||||
|
--bg-card: #232730;
|
||||||
|
--bg-input: #14171d;
|
||||||
|
--border: #2c313b;
|
||||||
|
--border-strong: #3a3f4a;
|
||||||
|
--text: #e8ebf0;
|
||||||
|
--muted: #8b93a1;
|
||||||
|
--accent: #3b82f6;
|
||||||
|
--accent-hover: #2563eb;
|
||||||
|
--danger: #b91c1c;
|
||||||
|
--danger-hover: #dc2626;
|
||||||
|
--ok: #16a34a;
|
||||||
|
--warn: #f59e0b;
|
||||||
|
--radius: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
* { box-sizing: border-box; }
|
||||||
|
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
|
||||||
|
background: var(--bg);
|
||||||
|
color: var(--text);
|
||||||
|
font-size: 15px;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- Header ---------- */
|
||||||
|
header {
|
||||||
|
padding: 14px 24px;
|
||||||
|
background: var(--bg-elev);
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
.brand { display: flex; align-items: center; gap: 12px; }
|
||||||
|
.brand .logo {
|
||||||
|
font-size: 1.6em;
|
||||||
|
color: var(--accent);
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
header h1 { margin: 0; font-size: 1.25em; }
|
||||||
|
header .meta { color: var(--muted); font-size: 0.82em; }
|
||||||
|
|
||||||
|
/* ---------- Tabs ---------- */
|
||||||
|
.tabs {
|
||||||
|
display: flex;
|
||||||
|
gap: 4px;
|
||||||
|
padding: 0 16px;
|
||||||
|
background: var(--bg-elev);
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
overflow-x: auto;
|
||||||
|
}
|
||||||
|
.tab {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
border-bottom: 2px solid transparent;
|
||||||
|
color: var(--muted);
|
||||||
|
padding: 12px 16px;
|
||||||
|
cursor: pointer;
|
||||||
|
font: inherit;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.tab:hover { color: var(--text); }
|
||||||
|
.tab.active {
|
||||||
|
color: var(--text);
|
||||||
|
border-bottom-color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- Layout ---------- */
|
||||||
|
main {
|
||||||
|
max-width: 960px;
|
||||||
|
margin: 20px auto;
|
||||||
|
padding: 0 16px 48px;
|
||||||
|
}
|
||||||
|
.panel { display: none; flex-direction: column; gap: 16px; }
|
||||||
|
.panel.active { display: flex; }
|
||||||
|
|
||||||
|
.card {
|
||||||
|
background: var(--bg-card);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
padding: 18px 20px;
|
||||||
|
}
|
||||||
|
.card h2 { margin: 0 0 14px; font-size: 1.05em; }
|
||||||
|
.card-head {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
.card-head h2 { margin: 0; }
|
||||||
|
.card-actions { margin-top: 16px; display: flex; gap: 10px; }
|
||||||
|
|
||||||
|
/* ---------- Forms ---------- */
|
||||||
|
.grid-form {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
|
||||||
|
gap: 14px;
|
||||||
|
}
|
||||||
|
.row { display: flex; gap: 12px; flex-wrap: wrap; align-items: flex-end; }
|
||||||
|
label { display: flex; flex-direction: column; gap: 5px; font-size: 0.88em; color: var(--muted); }
|
||||||
|
label.check { flex-direction: row; align-items: center; gap: 8px; color: var(--text); }
|
||||||
|
|
||||||
|
input[type="text"], input[type="number"], input[type="password"], select {
|
||||||
|
background: var(--bg-input);
|
||||||
|
border: 1px solid var(--border-strong);
|
||||||
|
color: var(--text);
|
||||||
|
padding: 8px 10px;
|
||||||
|
border-radius: 6px;
|
||||||
|
font: inherit;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
input:focus, select:focus { border-color: var(--accent); outline: none; }
|
||||||
|
input[type="color"] {
|
||||||
|
width: 48px; height: 34px; padding: 2px;
|
||||||
|
background: var(--bg-input); border: 1px solid var(--border-strong);
|
||||||
|
border-radius: 6px; cursor: pointer;
|
||||||
|
}
|
||||||
|
input[type="range"] { width: 100%; accent-color: var(--accent); }
|
||||||
|
input[type="file"] { font-size: 0.85em; color: var(--muted); }
|
||||||
|
|
||||||
|
/* ---------- Buttons ---------- */
|
||||||
|
button {
|
||||||
|
background: var(--bg-elev);
|
||||||
|
color: var(--text);
|
||||||
|
border: 1px solid var(--border-strong);
|
||||||
|
padding: 8px 14px;
|
||||||
|
border-radius: 6px;
|
||||||
|
cursor: pointer;
|
||||||
|
font: inherit;
|
||||||
|
}
|
||||||
|
button:hover { border-color: var(--accent); }
|
||||||
|
button.primary { background: var(--accent); border-color: var(--accent); color: #fff; }
|
||||||
|
button.primary:hover { background: var(--accent-hover); }
|
||||||
|
button.del { background: var(--danger); border-color: var(--danger); color: #fff; }
|
||||||
|
button.del:hover { background: var(--danger-hover); }
|
||||||
|
button.ghost { background: transparent; }
|
||||||
|
button.icon-only { padding: 6px 9px; line-height: 1; }
|
||||||
|
|
||||||
|
/* ---------- Hints ---------- */
|
||||||
|
.hint { color: var(--muted); font-size: 0.85em; margin: 0 0 12px; }
|
||||||
|
.warn { color: var(--warn); font-size: 0.85em; margin: 0 0 12px; }
|
||||||
|
.empty { color: var(--muted); text-align: center; padding: 24px 0; }
|
||||||
|
|
||||||
|
/* ---------- Icons grid ---------- */
|
||||||
|
.icon-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(96px, 1fr));
|
||||||
|
gap: 10px;
|
||||||
|
margin-top: 14px;
|
||||||
|
}
|
||||||
|
.icon-tile {
|
||||||
|
background: var(--bg-input);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 8px;
|
||||||
|
text-align: center;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
.icon-tile img { width: 100%; height: 64px; object-fit: contain; }
|
||||||
|
.icon-tile span { display: block; font-size: 0.72em; color: var(--muted); word-break: break-all; }
|
||||||
|
.icon-tile .del { position: absolute; top: 4px; right: 4px; }
|
||||||
|
|
||||||
|
/* ---------- Buttons list ---------- */
|
||||||
|
.buttons-list { list-style: none; margin: 0; padding: 0; }
|
||||||
|
.buttons-list li {
|
||||||
|
background: var(--bg-input);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 10px 12px;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
.buttons-list .handle { cursor: grab; font-size: 1.4em; color: var(--muted); user-select: none; }
|
||||||
|
.buttons-list .btn-icon { width: 38px; height: 38px; object-fit: contain; }
|
||||||
|
.buttons-list .btn-info { flex: 1; display: flex; flex-direction: column; }
|
||||||
|
.buttons-list .btn-info small { color: var(--muted); font-size: 0.8em; }
|
||||||
|
.buttons-list .swatch { width: 18px; height: 18px; border-radius: 50%; border: 1px solid var(--border-strong); }
|
||||||
|
.buttons-list li.sortable-ghost { opacity: 0.4; }
|
||||||
|
.buttons-list li.sortable-chosen { border-color: var(--accent); }
|
||||||
|
|
||||||
|
/* ---------- Feedback preview ---------- */
|
||||||
|
.preview-row { display: flex; gap: 24px; margin-top: 16px; }
|
||||||
|
.preview-dot {
|
||||||
|
width: 72px; height: 72px; border-radius: 50%;
|
||||||
|
display: flex; align-items: center; justify-content: center;
|
||||||
|
background: #7b007b; color: #fff; font-weight: 600; font-size: 0.85em;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- Network ---------- */
|
||||||
|
.net-status { font-size: 0.9em; }
|
||||||
|
.net-device {
|
||||||
|
background: var(--bg-input);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 10px 12px;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
.net-device .name { font-weight: 600; }
|
||||||
|
.net-device .detail { color: var(--muted); font-size: 0.85em; }
|
||||||
|
.badge { display: inline-block; padding: 1px 8px; border-radius: 999px; font-size: 0.75em; }
|
||||||
|
.badge.up { background: rgba(22,163,74,0.2); color: #4ade80; }
|
||||||
|
.badge.down { background: rgba(185,28,28,0.2); color: #f87171; }
|
||||||
|
.static-only { display: none; }
|
||||||
|
.grid-form.show-static .static-only { display: flex; }
|
||||||
|
|
||||||
|
/* ---------- Update ---------- */
|
||||||
|
.kv { display: flex; flex-direction: column; gap: 8px; }
|
||||||
|
.kv > div { display: flex; gap: 12px; }
|
||||||
|
.kv span { color: var(--muted); min-width: 80px; }
|
||||||
|
.kv code { word-break: break-all; }
|
||||||
|
.update-status {
|
||||||
|
margin-top: 14px;
|
||||||
|
padding: 10px 12px;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: var(--bg-input);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
font-size: 0.88em;
|
||||||
|
}
|
||||||
|
.update-status.ok { border-color: var(--ok); }
|
||||||
|
.update-status.fail { border-color: var(--danger); }
|
||||||
|
.update-status.busy { border-color: var(--warn); }
|
||||||
|
|
||||||
|
/* ---------- Dialog ---------- */
|
||||||
|
dialog {
|
||||||
|
background: var(--bg-card);
|
||||||
|
border: 1px solid var(--border-strong);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
color: var(--text);
|
||||||
|
padding: 22px;
|
||||||
|
min-width: 360px;
|
||||||
|
}
|
||||||
|
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; }
|
||||||
|
|
||||||
|
/* ---------- Toast ---------- */
|
||||||
|
.toast {
|
||||||
|
position: fixed; bottom: 24px; right: 24px;
|
||||||
|
background: var(--accent); color: #fff;
|
||||||
|
padding: 12px 18px; border-radius: 8px;
|
||||||
|
box-shadow: 0 8px 24px rgba(0,0,0,0.4);
|
||||||
|
z-index: 9999;
|
||||||
|
}
|
||||||
|
.toast.error { background: var(--danger); }
|
||||||
|
|
||||||
|
/* ---------- Login ---------- */
|
||||||
|
.login-body {
|
||||||
|
min-height: 100vh;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
background: linear-gradient(135deg, #0f1115 0%, #1f2330 100%);
|
||||||
|
}
|
||||||
|
.login-card {
|
||||||
|
background: var(--bg-card);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 30px 34px;
|
||||||
|
width: 330px;
|
||||||
|
box-shadow: 0 12px 36px rgba(0,0,0,0.45);
|
||||||
|
}
|
||||||
|
.login-card h1 { margin: 0 0 4px; font-size: 1.5em; }
|
||||||
|
.login-card .muted { margin: 0 0 22px; color: var(--muted); font-size: 0.9em; }
|
||||||
|
.login-card form { display: flex; flex-direction: column; gap: 16px; }
|
||||||
|
.login-card .error {
|
||||||
|
background: rgba(185,28,28,0.18);
|
||||||
|
border: 1px solid var(--danger);
|
||||||
|
color: #fca5a5;
|
||||||
|
padding: 9px 12px;
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: 0.9em;
|
||||||
|
}
|
||||||
|
.login-card button.primary { width: 100%; padding: 11px; font-size: 1em; }
|
||||||
@@ -0,0 +1,266 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="ru">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>ButtonTask — конфигуратор</title>
|
||||||
|
<link rel="stylesheet" href="{{ url_for('static', filename='style.css') }}">
|
||||||
|
<script src="https://cdn.jsdelivr.net/npm/sortablejs@1.15.0/Sortable.min.js" defer></script>
|
||||||
|
<script src="{{ url_for('static', filename='app.js') }}" defer></script>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<header>
|
||||||
|
<div class="brand">
|
||||||
|
<span class="logo">●</span>
|
||||||
|
<div>
|
||||||
|
<h1>ButtonTask</h1>
|
||||||
|
<span class="meta">Веб-конфигуратор · версия {{ version.version }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<a href="{{ url_for('auth.logout') }}" class="logout"><button type="button" class="ghost">Выйти</button></a>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<nav class="tabs" role="tablist">
|
||||||
|
<button class="tab active" data-tab="buttons">Кнопки</button>
|
||||||
|
<button class="tab" data-tab="appearance">Внешний вид</button>
|
||||||
|
<button class="tab" data-tab="network">Сеть</button>
|
||||||
|
<button class="tab" data-tab="update">Обновление</button>
|
||||||
|
<button class="tab" data-tab="security">Безопасность</button>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<main>
|
||||||
|
<!-- ============ Кнопки ============ -->
|
||||||
|
<section class="panel active" id="tab-buttons">
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-head">
|
||||||
|
<h2>Кнопки</h2>
|
||||||
|
<button id="add-btn" class="primary">+ Добавить</button>
|
||||||
|
</div>
|
||||||
|
<p class="hint">Перетаскивайте за «≡» для изменения порядка.</p>
|
||||||
|
<ul id="buttons-list" class="buttons-list">
|
||||||
|
{% for b in config.buttons %}
|
||||||
|
<li data-id="{{ b.id }}">
|
||||||
|
<span class="handle">≡</span>
|
||||||
|
<img class="btn-icon" src="{{ url_for('api.serve_icon', fname=b.iconPath) if b.iconPath else '' }}" alt="">
|
||||||
|
<div class="btn-info">
|
||||||
|
<strong>{{ b.label }}</strong>
|
||||||
|
<small>{{ b.action.type }}: {{ b.action.url }}</small>
|
||||||
|
</div>
|
||||||
|
<span class="swatch" style="background: {{ b.color or '#7b007b' }}"></span>
|
||||||
|
<button class="edit icon-only" data-id="{{ b.id }}" title="Редактировать">✎</button>
|
||||||
|
<button class="del icon-only" data-id="{{ b.id }}" title="Удалить">✕</button>
|
||||||
|
</li>
|
||||||
|
{% endfor %}
|
||||||
|
</ul>
|
||||||
|
{% if not config.buttons %}
|
||||||
|
<p class="empty">Кнопок пока нет. Нажмите «Добавить».</p>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- ============ Внешний вид ============ -->
|
||||||
|
<section class="panel" id="tab-appearance">
|
||||||
|
<div class="card">
|
||||||
|
<h2>Расположение</h2>
|
||||||
|
<div class="grid-form">
|
||||||
|
<label>Режим
|
||||||
|
<select id="layout-mode">
|
||||||
|
<option value="grid" {% if config.layout.mode == 'grid' %}selected{% endif %}>Сетка</option>
|
||||||
|
<option value="list" {% if config.layout.mode == 'list' %}selected{% endif %}>Список</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label>Колонок
|
||||||
|
<input type="number" id="layout-columns" min="1" max="8" value="{{ config.layout.columns }}">
|
||||||
|
</label>
|
||||||
|
<label>Отступ
|
||||||
|
<input type="number" id="layout-spacing" min="0" max="40" value="{{ config.layout.spacing }}">
|
||||||
|
</label>
|
||||||
|
<label class="check">
|
||||||
|
<input type="checkbox" id="layout-show-labels" {% if config.layout.showLabels %}checked{% endif %}>
|
||||||
|
Показывать подписи
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div class="card-actions">
|
||||||
|
<button id="save-layout" class="primary">Сохранить расположение</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<h2>Фон</h2>
|
||||||
|
<div class="grid-form">
|
||||||
|
<label>Тип
|
||||||
|
<select id="bg-type">
|
||||||
|
<option value="gradient" {% if config.background.type == 'gradient' %}selected{% endif %}>Градиент</option>
|
||||||
|
<option value="solid" {% if config.background.type == 'solid' %}selected{% endif %}>Цвет</option>
|
||||||
|
<option value="image" {% if config.background.type == 'image' %}selected{% endif %}>Картинка</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label>Цвет 1 <input type="color" id="bg-c1" value="{{ config.background.color1 }}"></label>
|
||||||
|
<label>Цвет 2 <input type="color" id="bg-c2" value="{{ config.background.color2 }}"></label>
|
||||||
|
<label class="check">
|
||||||
|
<input type="checkbox" id="bg-animated" {% if config.background.animated %}checked{% endif %}>
|
||||||
|
Анимация
|
||||||
|
</label>
|
||||||
|
<label>Картинка
|
||||||
|
<select id="bg-image">
|
||||||
|
<option value="">— нет —</option>
|
||||||
|
{% for ic in icons %}
|
||||||
|
<option value="{{ ic }}" {% if config.background.imagePath == ic %}selected{% endif %}>{{ ic }}</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div class="card-actions">
|
||||||
|
<button id="save-bg" class="primary">Сохранить фон</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<h2>Обводка отклика</h2>
|
||||||
|
<p class="hint">Цвет и ширина свечения вокруг кнопки при успехе и ошибке.</p>
|
||||||
|
<div class="grid-form">
|
||||||
|
<label>Цвет «ОК» <input type="color" id="fb-ok-color" value="{{ config.feedback.okColor }}"></label>
|
||||||
|
<label>Ширина «ОК»: <span id="fb-ok-width-val">{{ config.feedback.okWidth }}</span>
|
||||||
|
<input type="range" id="fb-ok-width" min="0" max="30" value="{{ config.feedback.okWidth }}">
|
||||||
|
</label>
|
||||||
|
<label>Цвет «Ошибка» <input type="color" id="fb-error-color" value="{{ config.feedback.errorColor }}"></label>
|
||||||
|
<label>Ширина «Ошибка»: <span id="fb-error-width-val">{{ config.feedback.errorWidth }}</span>
|
||||||
|
<input type="range" id="fb-error-width" min="0" max="30" value="{{ config.feedback.errorWidth }}">
|
||||||
|
</label>
|
||||||
|
<label>Радиус свечения: <span id="fb-glow-val">{{ config.feedback.glowRadius }}</span>
|
||||||
|
<input type="range" id="fb-glow" min="0" max="60" value="{{ config.feedback.glowRadius }}">
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div class="preview-row">
|
||||||
|
<div class="preview-dot" id="fb-preview-ok">OK</div>
|
||||||
|
<div class="preview-dot" id="fb-preview-error">ERR</div>
|
||||||
|
</div>
|
||||||
|
<div class="card-actions">
|
||||||
|
<button id="save-feedback" class="primary">Сохранить обводку</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<h2>Иконки</h2>
|
||||||
|
<div class="row">
|
||||||
|
<input type="file" id="icon-file" accept=".png,.jpg,.jpeg,.bmp,.svg,.gif">
|
||||||
|
<button id="upload-icon">Загрузить</button>
|
||||||
|
</div>
|
||||||
|
<div id="icon-grid" class="icon-grid">
|
||||||
|
{% for ic in icons %}
|
||||||
|
<div class="icon-tile" data-name="{{ ic }}">
|
||||||
|
<img src="{{ url_for('api.serve_icon', fname=ic) }}" alt="{{ ic }}">
|
||||||
|
<span>{{ ic }}</span>
|
||||||
|
<button class="del icon-only" data-name="{{ ic }}">✕</button>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- ============ Сеть ============ -->
|
||||||
|
<section class="panel" id="tab-network">
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-head">
|
||||||
|
<h2>Ethernet</h2>
|
||||||
|
<button id="net-refresh" class="ghost">↻ Обновить</button>
|
||||||
|
</div>
|
||||||
|
<div id="net-status" class="net-status">Загрузка состояния…</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<h2>Настройка интерфейса</h2>
|
||||||
|
<p class="warn">Изменение настроек может разорвать текущее соединение и сменить IP-адрес устройства.</p>
|
||||||
|
<div class="grid-form">
|
||||||
|
<label>Интерфейс
|
||||||
|
<select id="net-iface"></select>
|
||||||
|
</label>
|
||||||
|
<label>Режим
|
||||||
|
<select id="net-mode">
|
||||||
|
<option value="dhcp">DHCP (автоматически)</option>
|
||||||
|
<option value="static">Статический IP</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label class="static-only">IP-адрес <input type="text" id="net-address" placeholder="192.168.1.50"></label>
|
||||||
|
<label class="static-only">Маска (префикс) <input type="number" id="net-prefix" min="1" max="32" value="24"></label>
|
||||||
|
<label class="static-only">Шлюз <input type="text" id="net-gateway" placeholder="192.168.1.1"></label>
|
||||||
|
<label class="static-only">DNS <input type="text" id="net-dns" placeholder="8.8.8.8, 1.1.1.1"></label>
|
||||||
|
</div>
|
||||||
|
<div class="card-actions">
|
||||||
|
<button id="net-apply" class="primary">Применить</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- ============ Обновление ============ -->
|
||||||
|
<section class="panel" id="tab-update">
|
||||||
|
<div class="card">
|
||||||
|
<h2>Текущая версия</h2>
|
||||||
|
<div id="update-current" class="kv">
|
||||||
|
<div><span>Версия</span><strong id="update-version">{{ version.version }}</strong></div>
|
||||||
|
<div><span>Путь</span><code id="update-path">{{ version.path }}</code></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<h2>Установить обновление</h2>
|
||||||
|
<p class="hint">Загрузите пакет <code>.tar.gz</code> (бинарь ButtonTask + webconfig + manifest.json). После применения выполняется проверка работоспособности; при ошибке запуска произойдёт автоматический откат.</p>
|
||||||
|
<div class="row">
|
||||||
|
<input type="file" id="update-file" accept=".tar.gz,.tgz">
|
||||||
|
<button id="update-upload" class="primary">Загрузить и применить</button>
|
||||||
|
</div>
|
||||||
|
<div id="update-status" class="update-status">Статус: проверка…</div>
|
||||||
|
<div class="card-actions">
|
||||||
|
<button id="update-rollback" class="del">Откатить на предыдущую версию</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- ============ Безопасность ============ -->
|
||||||
|
<section class="panel" id="tab-security">
|
||||||
|
<div class="card">
|
||||||
|
<h2>Пароль</h2>
|
||||||
|
<p class="hint">Используется и для входа в веб-конфигуратор, и для настроек в приложении.</p>
|
||||||
|
<div class="row">
|
||||||
|
<label>Новый пароль
|
||||||
|
<input type="password" id="new-password" placeholder="Оставьте пустым чтобы не менять">
|
||||||
|
</label>
|
||||||
|
<button id="save-password" class="primary">Сохранить пароль</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<!-- Edit dialog -->
|
||||||
|
<dialog id="btn-dialog">
|
||||||
|
<form method="dialog" id="btn-form">
|
||||||
|
<h3 id="btn-dialog-title">Кнопка</h3>
|
||||||
|
<input type="hidden" id="b-id">
|
||||||
|
<label>Подпись <input type="text" id="b-label" required></label>
|
||||||
|
<label>Иконка
|
||||||
|
<select id="b-icon">
|
||||||
|
<option value="">— нет —</option>
|
||||||
|
{% for ic in icons %}
|
||||||
|
<option value="{{ ic }}">{{ ic }}</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label>Метод
|
||||||
|
<select id="b-type">
|
||||||
|
<option value="http_get">http_get</option>
|
||||||
|
<option value="http_post">http_post</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label>URL <input type="text" id="b-url" required></label>
|
||||||
|
<label>Текст успеха <input type="text" id="b-success"></label>
|
||||||
|
<label>Текст ошибки <input type="text" id="b-error" value="Ошибка"></label>
|
||||||
|
<label>Цвет кнопки <input type="color" id="b-color" value="#7b007b"></label>
|
||||||
|
<menu>
|
||||||
|
<button value="cancel">Отмена</button>
|
||||||
|
<button id="b-save" value="ok" type="submit" class="primary">Сохранить</button>
|
||||||
|
</menu>
|
||||||
|
</form>
|
||||||
|
</dialog>
|
||||||
|
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="ru">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>ButtonTask — вход</title>
|
||||||
|
<link rel="stylesheet" href="{{ url_for('static', filename='style.css') }}">
|
||||||
|
</head>
|
||||||
|
<body class="login-body">
|
||||||
|
<main class="login-card">
|
||||||
|
<h1>ButtonTask</h1>
|
||||||
|
<p class="muted">Веб-конфигуратор</p>
|
||||||
|
|
||||||
|
{% if error %}
|
||||||
|
<div class="error">{{ error }}</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<form method="post" action="{{ url_for('auth.login') }}{% if next %}?next={{ next }}{% endif %}">
|
||||||
|
<label>
|
||||||
|
<span>Пароль</span>
|
||||||
|
<input type="password" name="password" autofocus required>
|
||||||
|
</label>
|
||||||
|
{% if next %}
|
||||||
|
<input type="hidden" name="next" value="{{ next }}">
|
||||||
|
{% endif %}
|
||||||
|
<button type="submit" class="primary">Войти</button>
|
||||||
|
</form>
|
||||||
|
</main>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,123 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""OTA software update for ButtonTask.
|
||||||
|
|
||||||
|
The web side receives an uploaded package (tar.gz containing the ButtonTask
|
||||||
|
binary + webconfig/ + manifest.json), validates it, then hands the actual
|
||||||
|
swap/health-check/rollback to the privileged, detached helper `bt-update`.
|
||||||
|
Because the web service itself is part of the package being replaced, the
|
||||||
|
helper is launched detached (setsid) so it survives the service restart.
|
||||||
|
Status is reported via run/update-status.json.
|
||||||
|
"""
|
||||||
|
import hashlib
|
||||||
|
import json
|
||||||
|
import subprocess
|
||||||
|
import time
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from flask import Blueprint, abort, jsonify, request
|
||||||
|
|
||||||
|
from auth import login_required
|
||||||
|
from paths import (UPDATE_STATUS, current_link, install_root, run_dir,
|
||||||
|
scripts_dir)
|
||||||
|
|
||||||
|
updater_bp = Blueprint("updater", __name__)
|
||||||
|
|
||||||
|
STAGING_NAME = "staging"
|
||||||
|
|
||||||
|
|
||||||
|
def _read_manifest(directory):
|
||||||
|
mf = Path(directory) / "manifest.json"
|
||||||
|
if not mf.exists():
|
||||||
|
return {}
|
||||||
|
try:
|
||||||
|
with mf.open("r", encoding="utf-8") as f:
|
||||||
|
return json.load(f)
|
||||||
|
except (OSError, ValueError):
|
||||||
|
return {}
|
||||||
|
|
||||||
|
|
||||||
|
def current_version():
|
||||||
|
link = current_link()
|
||||||
|
target = link.resolve() if link.exists() else None
|
||||||
|
manifest = _read_manifest(target) if target else {}
|
||||||
|
return {
|
||||||
|
"version": manifest.get("version", "unknown"),
|
||||||
|
"path": str(target) if target else "",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def read_status():
|
||||||
|
p = run_dir() / UPDATE_STATUS
|
||||||
|
if not p.exists():
|
||||||
|
return {"state": "idle"}
|
||||||
|
try:
|
||||||
|
with p.open("r", encoding="utf-8") as f:
|
||||||
|
return json.load(f)
|
||||||
|
except (OSError, ValueError):
|
||||||
|
return {"state": "unknown"}
|
||||||
|
|
||||||
|
|
||||||
|
def _staging_dir():
|
||||||
|
p = install_root() / STAGING_NAME
|
||||||
|
p.mkdir(parents=True, exist_ok=True)
|
||||||
|
return p
|
||||||
|
|
||||||
|
|
||||||
|
@updater_bp.route("/api/update/status", methods=["GET"])
|
||||||
|
@login_required
|
||||||
|
def api_update_status():
|
||||||
|
return jsonify({"current": current_version(), "status": read_status()})
|
||||||
|
|
||||||
|
|
||||||
|
@updater_bp.route("/api/update", methods=["POST"])
|
||||||
|
@login_required
|
||||||
|
def api_update():
|
||||||
|
if "file" not in request.files:
|
||||||
|
abort(400, "package file required")
|
||||||
|
fobj = request.files["file"]
|
||||||
|
fname = Path(fobj.filename or "").name
|
||||||
|
if not (fname.endswith(".tar.gz") or fname.endswith(".tgz")):
|
||||||
|
abort(400, "package must be a .tar.gz")
|
||||||
|
|
||||||
|
staging = _staging_dir()
|
||||||
|
pkg_path = staging / f"upload-{int(time.time())}.tar.gz"
|
||||||
|
fobj.save(str(pkg_path))
|
||||||
|
|
||||||
|
# Optional integrity check against a client-provided sha256.
|
||||||
|
expected = (request.form.get("sha256") or "").strip().lower()
|
||||||
|
if expected:
|
||||||
|
h = hashlib.sha256()
|
||||||
|
with pkg_path.open("rb") as f:
|
||||||
|
for chunk in iter(lambda: f.read(1 << 20), b""):
|
||||||
|
h.update(chunk)
|
||||||
|
if h.hexdigest() != expected:
|
||||||
|
pkg_path.unlink(missing_ok=True)
|
||||||
|
abort(400, "sha256 mismatch")
|
||||||
|
|
||||||
|
script = str(scripts_dir() / "bt-update")
|
||||||
|
# start_new_session detaches the child (setsid) so it survives the web
|
||||||
|
# service restart performed mid-update.
|
||||||
|
try:
|
||||||
|
subprocess.Popen(
|
||||||
|
["sudo", "-n", script, "stage", str(pkg_path)],
|
||||||
|
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
|
||||||
|
start_new_session=True,
|
||||||
|
)
|
||||||
|
except FileNotFoundError as e:
|
||||||
|
return jsonify({"ok": False, "error": str(e)}), 500
|
||||||
|
return jsonify({"ok": True, "state": "started"})
|
||||||
|
|
||||||
|
|
||||||
|
@updater_bp.route("/api/update/rollback", methods=["POST"])
|
||||||
|
@login_required
|
||||||
|
def api_update_rollback():
|
||||||
|
script = str(scripts_dir() / "bt-update")
|
||||||
|
try:
|
||||||
|
subprocess.Popen(
|
||||||
|
["sudo", "-n", script, "rollback"],
|
||||||
|
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
|
||||||
|
start_new_session=True,
|
||||||
|
)
|
||||||
|
except FileNotFoundError as e:
|
||||||
|
return jsonify({"ok": False, "error": str(e)}), 500
|
||||||
|
return jsonify({"ok": True, "state": "rollback-started"})
|
||||||