Add broker management and writer transfer UI
This commit is contained in:
@@ -35,6 +35,7 @@ idf_component_register(
|
||||
"web_account_settings.c"
|
||||
"web_network_settings.c"
|
||||
"web_display_settings.c"
|
||||
"web_broker_settings.c"
|
||||
"web_admin_tickets.c"
|
||||
"web_admin_transport.c"
|
||||
"web_assets_data.c"
|
||||
|
||||
+17
-2
@@ -20,6 +20,7 @@
|
||||
#include "web_account_settings.h"
|
||||
#include "web_network_settings.h"
|
||||
#include "web_display_settings.h"
|
||||
#include "web_broker_settings.h"
|
||||
|
||||
#define ADMIN_SSH_CONSOLE_MAX_SESSIONS 2U
|
||||
#define ADMIN_SSH_CONSOLE_OUTPUT_CAPACITY 4096U
|
||||
@@ -89,6 +90,7 @@ typedef enum {
|
||||
ADMIN_REQUEST_ACCOUNT_SETTINGS,
|
||||
ADMIN_REQUEST_NETWORK_SETTINGS,
|
||||
ADMIN_REQUEST_DISPLAY_SETTINGS,
|
||||
ADMIN_REQUEST_BROKER_SETTINGS,
|
||||
} admin_request_origin_t;
|
||||
|
||||
typedef struct {
|
||||
@@ -103,6 +105,7 @@ typedef struct {
|
||||
uint32_t account_settings_id;
|
||||
uint32_t network_settings_id;
|
||||
uint32_t display_settings_id;
|
||||
uint32_t broker_settings_id;
|
||||
};
|
||||
} admin_request_t;
|
||||
|
||||
@@ -698,6 +701,16 @@ esp_err_t admin_ssh_console_submit_display_settings(uint32_t id)
|
||||
return xQueueSend(s_request_queue, &request, 0U) == pdTRUE ? ESP_OK : ESP_ERR_TIMEOUT;
|
||||
}
|
||||
|
||||
esp_err_t admin_ssh_console_submit_broker_settings(uint32_t id)
|
||||
{
|
||||
taskENTER_CRITICAL(&s_lock);
|
||||
bool ready = s_dispatch_ready;
|
||||
taskEXIT_CRITICAL(&s_lock);
|
||||
if (!ready || !id) return ESP_ERR_INVALID_STATE;
|
||||
admin_request_t request = {.origin = ADMIN_REQUEST_BROKER_SETTINGS, .broker_settings_id = id};
|
||||
return xQueueSend(s_request_queue, &request, 0U) == pdTRUE ? ESP_OK : ESP_ERR_TIMEOUT;
|
||||
}
|
||||
|
||||
static void worker_task(void *context)
|
||||
{
|
||||
(void)context;
|
||||
@@ -707,11 +720,13 @@ static void worker_task(void *context)
|
||||
continue;
|
||||
}
|
||||
if (request.origin == ADMIN_REQUEST_SERIAL_SETTINGS || request.origin == ADMIN_REQUEST_ACCOUNT_SETTINGS ||
|
||||
request.origin == ADMIN_REQUEST_NETWORK_SETTINGS || request.origin == ADMIN_REQUEST_DISPLAY_SETTINGS) {
|
||||
request.origin == ADMIN_REQUEST_NETWORK_SETTINGS || request.origin == ADMIN_REQUEST_DISPLAY_SETTINGS ||
|
||||
request.origin == ADMIN_REQUEST_BROKER_SETTINGS) {
|
||||
if (request.origin == ADMIN_REQUEST_SERIAL_SETTINGS) web_serial_settings_execute(request.serial_settings_id);
|
||||
else if (request.origin == ADMIN_REQUEST_ACCOUNT_SETTINGS) web_account_settings_execute(request.account_settings_id);
|
||||
else if (request.origin == ADMIN_REQUEST_NETWORK_SETTINGS) web_network_settings_execute(request.network_settings_id);
|
||||
else web_display_settings_execute(request.display_settings_id);
|
||||
else if (request.origin == ADMIN_REQUEST_DISPLAY_SETTINGS) web_display_settings_execute(request.display_settings_id);
|
||||
else web_broker_settings_execute(request.broker_settings_id);
|
||||
secure_wipe(&request, sizeof(request));
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -19,6 +19,7 @@ esp_err_t admin_ssh_console_submit_serial_settings(uint32_t id);
|
||||
esp_err_t admin_ssh_console_submit_account_settings(uint32_t id);
|
||||
esp_err_t admin_ssh_console_submit_network_settings(uint32_t id);
|
||||
esp_err_t admin_ssh_console_submit_display_settings(uint32_t id);
|
||||
esp_err_t admin_ssh_console_submit_broker_settings(uint32_t id);
|
||||
|
||||
/* Fits the longest supported ECDSA P-256 OpenSSH key import command. */
|
||||
#define ADMIN_SSH_CONSOLE_COMMAND_LINE_CAPACITY 256U
|
||||
|
||||
+51
-5
@@ -47,6 +47,8 @@ static session_broker_slot_t s_slots[SESSION_BROKER_MAX_CLIENTS];
|
||||
static session_broker_client_id_t s_writer_id;
|
||||
static uint32_t s_connected_clients;
|
||||
static uint64_t s_event_sequence;
|
||||
/* Saturation disables management confirmations, never ordinary recovery. */
|
||||
static uint32_t s_writer_generation = 1U;
|
||||
static session_broker_global_counters_t s_counters;
|
||||
static bool s_initialized;
|
||||
|
||||
@@ -102,6 +104,10 @@ static void broadcast_event_locked(session_broker_event_type_t type,
|
||||
session_broker_client_id_t client_id,
|
||||
session_broker_client_id_t writer_id)
|
||||
{
|
||||
if ((type == SESSION_BROKER_EVENT_WRITER_GRANTED ||
|
||||
type == SESSION_BROKER_EVENT_WRITER_RELEASED ||
|
||||
type == SESSION_BROKER_EVENT_WRITER_REVOKED) && s_writer_generation != UINT32_MAX)
|
||||
++s_writer_generation;
|
||||
session_broker_event_t event = {
|
||||
.sequence = ++s_event_sequence,
|
||||
.type = type,
|
||||
@@ -299,7 +305,8 @@ esp_err_t session_broker_connect(session_broker_client_type_t type,
|
||||
session_broker_slot_t *slot = NULL;
|
||||
size_t slot_index = 0U;
|
||||
for (; slot_index < SESSION_BROKER_MAX_CLIENTS; ++slot_index) {
|
||||
if (!s_slots[slot_index].connected) {
|
||||
if (!s_slots[slot_index].connected &&
|
||||
s_slots[slot_index].generation < SESSION_BROKER_MAX_GENERATION) {
|
||||
slot = &s_slots[slot_index];
|
||||
break;
|
||||
}
|
||||
@@ -319,9 +326,7 @@ esp_err_t session_broker_connect(session_broker_client_type_t type,
|
||||
}
|
||||
|
||||
uint32_t generation = slot->generation + 1U;
|
||||
if (generation == 0U || generation > SESSION_BROKER_MAX_GENERATION) {
|
||||
generation = 1U;
|
||||
}
|
||||
/* Exhausted slots are retired until reboot: no 29-bit ID reuse. */
|
||||
|
||||
xStreamBufferReset(slot->output);
|
||||
xQueueReset(slot->events);
|
||||
@@ -470,13 +475,19 @@ esp_err_t session_broker_release_writer(session_broker_client_id_t client_id)
|
||||
return ESP_OK;
|
||||
}
|
||||
|
||||
esp_err_t session_broker_force_writer(session_broker_client_id_t client_id)
|
||||
static esp_err_t broker_force_writer(session_broker_client_id_t client_id,
|
||||
uint32_t expected_generation)
|
||||
{
|
||||
if (!s_initialized) {
|
||||
return ESP_ERR_INVALID_STATE;
|
||||
}
|
||||
|
||||
xSemaphoreTake(s_mutex, portMAX_DELAY);
|
||||
if (expected_generation && (expected_generation == UINT32_MAX ||
|
||||
expected_generation != s_writer_generation)) {
|
||||
xSemaphoreGive(s_mutex);
|
||||
return ESP_ERR_INVALID_STATE;
|
||||
}
|
||||
session_broker_slot_t *new_writer = NULL;
|
||||
if (client_id != SESSION_BROKER_NO_CLIENT) {
|
||||
new_writer = find_slot_locked(client_id);
|
||||
@@ -522,6 +533,41 @@ esp_err_t session_broker_force_writer(session_broker_client_id_t client_id)
|
||||
return ESP_OK;
|
||||
}
|
||||
|
||||
esp_err_t session_broker_force_writer(session_broker_client_id_t client_id)
|
||||
{
|
||||
return broker_force_writer(client_id, 0);
|
||||
}
|
||||
|
||||
esp_err_t session_broker_assign_writer_current(session_broker_client_id_t client_id,
|
||||
uint32_t generation)
|
||||
{
|
||||
if (!client_id || !generation) return ESP_ERR_INVALID_ARG;
|
||||
return broker_force_writer(client_id, generation);
|
||||
}
|
||||
|
||||
esp_err_t session_broker_get_management_snapshot(session_broker_management_snapshot_t *snapshot)
|
||||
{
|
||||
if (!snapshot) return ESP_ERR_INVALID_ARG;
|
||||
if (!s_initialized) return ESP_ERR_INVALID_STATE;
|
||||
if (xSemaphoreTake(s_mutex, 0) != pdTRUE) return ESP_ERR_TIMEOUT;
|
||||
memset(snapshot, 0, sizeof(*snapshot));
|
||||
snapshot->generation = s_writer_generation;
|
||||
snapshot->writer_id = s_writer_id;
|
||||
for (size_t i = 0; i < SESSION_BROKER_MAX_CLIENTS; ++i) {
|
||||
const session_broker_slot_t *slot = &s_slots[i];
|
||||
if (!slot->connected) continue;
|
||||
session_broker_management_client_t *client = &snapshot->clients[snapshot->count++];
|
||||
client->id = slot->id;
|
||||
client->type = slot->type;
|
||||
memcpy(client->name, slot->name, sizeof(client->name));
|
||||
client->pending = xStreamBufferBytesAvailable(slot->output);
|
||||
client->high_water = slot->counters.output_high_water_bytes;
|
||||
client->dropped = slot->counters.output_dropped_bytes;
|
||||
}
|
||||
xSemaphoreGive(s_mutex);
|
||||
return ESP_OK;
|
||||
}
|
||||
|
||||
esp_err_t session_broker_force_release_writer(
|
||||
session_broker_client_id_t expected_writer_id)
|
||||
{
|
||||
|
||||
@@ -120,6 +120,31 @@ typedef struct {
|
||||
session_broker_global_counters_t counters;
|
||||
} session_broker_global_snapshot_t;
|
||||
|
||||
/* Compact, atomic, non-consuming management projection. No transport pointers. */
|
||||
typedef struct {
|
||||
session_broker_client_id_t id;
|
||||
session_broker_client_type_t type;
|
||||
char name[SESSION_BROKER_CLIENT_NAME_MAX + 1U];
|
||||
size_t pending, high_water;
|
||||
uint64_t dropped;
|
||||
} session_broker_management_client_t;
|
||||
typedef struct {
|
||||
uint32_t generation;
|
||||
session_broker_client_id_t writer_id;
|
||||
size_t count;
|
||||
session_broker_management_client_t clients[SESSION_BROKER_MAX_CLIENTS];
|
||||
} session_broker_management_snapshot_t;
|
||||
|
||||
/* Zero-wait atomic snapshot. Generation survives counter clears; UINT32_MAX
|
||||
* means confirmations exhausted until reboot. Every lease transition advances
|
||||
* it, including release/reacquire ABA. Client IDs never wrap within a boot. */
|
||||
esp_err_t session_broker_get_management_snapshot(session_broker_management_snapshot_t *snapshot);
|
||||
/* Nonzero target and generation required; compare + target validation + transfer
|
||||
* share the broker lock. Stale/exhausted generation or absent target has no effects.
|
||||
* Existing unconditional force remains available to recovery/console callers. */
|
||||
esp_err_t session_broker_assign_writer_current(session_broker_client_id_t client_id,
|
||||
uint32_t generation);
|
||||
|
||||
/*
|
||||
* Allocates all eight output streams and event queues, then starts the
|
||||
* permanent broker task. The serial service must already be initialized
|
||||
|
||||
@@ -0,0 +1,234 @@
|
||||
/* SPDX-License-Identifier: GPL-3.0-only */
|
||||
#include "web_broker_settings.h"
|
||||
|
||||
#include <inttypes.h>
|
||||
#include <stdio.h>
|
||||
#include <string.h>
|
||||
#include "admin_ssh_console.h"
|
||||
#include "esp_timer.h"
|
||||
#include "freertos/FreeRTOS.h"
|
||||
#include "secure_random.h"
|
||||
#include "session_broker.h"
|
||||
#include "web_cookie_auth.h"
|
||||
#include "web_httpd_adapter.h"
|
||||
|
||||
enum { IDLE, PENDING, OK, FAILED, CANCELLED, CONFLICT };
|
||||
static const char *const s_states[] = {"idle", "pending", "ok", "failed", "cancelled", "conflict"};
|
||||
typedef struct {
|
||||
uint32_t id;
|
||||
web_session_id_t session;
|
||||
user_principal_t principal;
|
||||
int64_t deadline;
|
||||
uint32_t generation, target;
|
||||
unsigned state;
|
||||
} broker_operation_t;
|
||||
static portMUX_TYPE s_lock = portMUX_INITIALIZER_UNLOCKED;
|
||||
static broker_operation_t s_operation;
|
||||
static uint32_t s_next_id;
|
||||
|
||||
/* Narrow flat JSON: exact action plus two unsigned decimal integers, no
|
||||
* escapes, duplicates, unknown fields, nesting, fractions or exponents. */
|
||||
static bool parse(const char *body, size_t length, broker_operation_t *operation)
|
||||
{
|
||||
const char *keys[] = {"action", "generation", "target"};
|
||||
unsigned seen = 0;
|
||||
size_t pos = 0;
|
||||
#define SPACE() while (pos < length && (body[pos] == ' ' || body[pos] == '\t' || body[pos] == '\r' || body[pos] == '\n')) ++pos
|
||||
#define TAKE(c) do { SPACE(); if (pos == length || body[pos++] != (c)) return false; } while (0)
|
||||
TAKE('{');
|
||||
for (unsigned field = 0; field < 3; ++field) {
|
||||
if (field) { TAKE(','); }
|
||||
TAKE('"');
|
||||
size_t start = pos;
|
||||
while (pos < length && body[pos] != '"') ++pos;
|
||||
if (pos == length) return false;
|
||||
unsigned key = 0;
|
||||
for (; key < 3; ++key)
|
||||
if (strlen(keys[key]) == pos - start && !memcmp(body + start, keys[key], pos - start)) break;
|
||||
if (key == 3 || (seen & (1U << key))) return false;
|
||||
++pos; TAKE(':'); SPACE();
|
||||
if (key == 0) {
|
||||
const char action[] = "\"assign\"";
|
||||
if (length - pos < sizeof(action) - 1 || memcmp(body + pos, action, sizeof(action) - 1)) return false;
|
||||
pos += sizeof(action) - 1;
|
||||
} else {
|
||||
uint32_t number = 0;
|
||||
start = pos;
|
||||
while (pos < length && body[pos] >= '0' && body[pos] <= '9') {
|
||||
unsigned digit = (unsigned)(body[pos++] - '0');
|
||||
if (number > (UINT32_MAX - digit) / 10U) return false;
|
||||
number = number * 10U + digit;
|
||||
}
|
||||
if (pos == start || (pos - start > 1 && body[start] == '0')) return false;
|
||||
if (key == 1) operation->generation = number;
|
||||
else operation->target = number;
|
||||
}
|
||||
seen |= 1U << key;
|
||||
}
|
||||
TAKE('}'); SPACE();
|
||||
#undef TAKE
|
||||
#undef SPACE
|
||||
return pos == length && seen == 7 && operation->target &&
|
||||
operation->generation && operation->generation != UINT32_MAX;
|
||||
}
|
||||
|
||||
void web_broker_settings_execute(uint32_t id)
|
||||
{
|
||||
broker_operation_t operation;
|
||||
taskENTER_CRITICAL(&s_lock);
|
||||
operation = s_operation;
|
||||
taskEXIT_CRITICAL(&s_lock);
|
||||
if (!id || operation.id != id || operation.state != PENDING) {
|
||||
secure_wipe(&operation, sizeof(operation));
|
||||
return;
|
||||
}
|
||||
bool current = false;
|
||||
esp_err_t error = web_session_store_check_principal(operation.session, &operation.principal, ¤t);
|
||||
unsigned state = CANCELLED;
|
||||
if (error == ESP_OK && current && operation.principal.role == USER_ROLE_ADMIN &&
|
||||
esp_timer_get_time() < operation.deadline) {
|
||||
error = session_broker_assign_writer_current(operation.target, operation.generation);
|
||||
state = error == ESP_OK ? OK :
|
||||
(error == ESP_ERR_INVALID_STATE || error == ESP_ERR_NOT_FOUND) ? CONFLICT : FAILED;
|
||||
}
|
||||
taskENTER_CRITICAL(&s_lock);
|
||||
if (s_operation.id == id && s_operation.state == PENDING) {
|
||||
s_operation.state = state;
|
||||
secure_wipe(&s_operation.principal, sizeof(s_operation.principal));
|
||||
}
|
||||
taskEXIT_CRITICAL(&s_lock);
|
||||
secure_wipe(&operation, sizeof(operation));
|
||||
}
|
||||
|
||||
static esp_err_t respond(httpd_req_t *request, const char *status, const char *body)
|
||||
{
|
||||
esp_err_t error = httpd_resp_set_status(request, status);
|
||||
if (error == ESP_OK) error = httpd_resp_set_type(request, "application/json; charset=utf-8");
|
||||
if (error == ESP_OK) error = httpd_resp_set_hdr(request, "Cache-Control", "no-store");
|
||||
if (error == ESP_OK) error = httpd_resp_set_hdr(request, "X-Content-Type-Options", "nosniff");
|
||||
if (error == ESP_OK) error = httpd_resp_set_hdr(request, "Referrer-Policy", "no-referrer");
|
||||
if (error == ESP_OK) error = httpd_resp_sendstr(request, body);
|
||||
return web_httpd_unread_body(request) ? ESP_FAIL : error;
|
||||
}
|
||||
|
||||
esp_err_t web_broker_operation_handler(httpd_req_t *request)
|
||||
{
|
||||
web_session_view_t view = {0};
|
||||
bool allowed = false;
|
||||
bool mutation = request->method == HTTP_POST;
|
||||
esp_err_t error = mutation
|
||||
? web_cookie_auth_require_json(request, 256, &view, &allowed)
|
||||
: web_cookie_auth_require(request, false, false, &view, &allowed);
|
||||
if (error != ESP_OK || !allowed) goto done;
|
||||
if (view.principal.role != USER_ROLE_ADMIN) {
|
||||
error = respond(request, "403 Forbidden", "{\"error\":\"admin_required\"}");
|
||||
goto done;
|
||||
}
|
||||
broker_operation_t operation = {0};
|
||||
if (mutation) {
|
||||
char type[40] = {0}, body[256];
|
||||
size_t received = 0;
|
||||
bool valid = request->content_len && request->content_len <= sizeof(body) &&
|
||||
httpd_req_get_hdr_value_str(request, "Content-Type", type, sizeof(type)) == ESP_OK &&
|
||||
(!strcmp(type, "application/json") || !strcmp(type, "application/json; charset=utf-8"));
|
||||
for (unsigned reads = 0; valid && received < request->content_len && reads < 4; ++reads) {
|
||||
int count = httpd_req_recv(request, body + received, request->content_len - received);
|
||||
if (count <= 0 || (size_t)count > request->content_len - received) valid = false;
|
||||
else received += (size_t)count;
|
||||
}
|
||||
valid = valid && received == request->content_len && parse(body, received, &operation);
|
||||
secure_wipe(body, sizeof(body));
|
||||
if (!valid) {
|
||||
error = respond(request, "400 Bad Request", "{\"error\":\"invalid_broker_request\"}");
|
||||
goto done;
|
||||
}
|
||||
operation.session = view.id;
|
||||
operation.principal = view.principal;
|
||||
operation.deadline = esp_timer_get_time() + 30000000LL;
|
||||
operation.state = PENDING;
|
||||
taskENTER_CRITICAL(&s_lock);
|
||||
bool busy = s_operation.state == PENDING || s_next_id == UINT32_MAX;
|
||||
if (!busy) {
|
||||
operation.id = ++s_next_id;
|
||||
s_operation = operation;
|
||||
}
|
||||
taskEXIT_CRITICAL(&s_lock);
|
||||
if (busy || admin_ssh_console_submit_broker_settings(operation.id) != ESP_OK) {
|
||||
taskENTER_CRITICAL(&s_lock);
|
||||
if (!busy && s_operation.id == operation.id) secure_wipe(&s_operation, sizeof(s_operation));
|
||||
taskEXIT_CRITICAL(&s_lock);
|
||||
error = httpd_resp_set_hdr(request, "Retry-After", "1");
|
||||
if (error == ESP_OK) error = respond(request, "503 Service Unavailable", "{\"error\":\"busy\"}");
|
||||
secure_wipe(&operation, sizeof(operation));
|
||||
goto done;
|
||||
}
|
||||
} else {
|
||||
taskENTER_CRITICAL(&s_lock);
|
||||
if (s_operation.session == view.id) {
|
||||
operation.id = s_operation.id;
|
||||
operation.state = s_operation.state;
|
||||
}
|
||||
taskEXIT_CRITICAL(&s_lock);
|
||||
}
|
||||
char response[96];
|
||||
int written = snprintf(response, sizeof(response), "{\"id\":%" PRIu32 ",\"action\":\"%s\",\"state\":\"%s\"}",
|
||||
operation.id, operation.id ? "assign" : "none", s_states[operation.state]);
|
||||
error = written < 0 || (size_t)written >= sizeof(response) ? ESP_FAIL :
|
||||
respond(request, mutation ? "202 Accepted" : "200 OK", response);
|
||||
secure_wipe(&operation, sizeof(operation));
|
||||
done:
|
||||
secure_wipe(&view, sizeof(view));
|
||||
web_httpd_wipe_request(request, web_httpd_unread_body(request));
|
||||
return error;
|
||||
}
|
||||
|
||||
esp_err_t web_broker_settings_handler(httpd_req_t *request)
|
||||
{
|
||||
web_session_view_t view = {0};
|
||||
bool allowed = false;
|
||||
esp_err_t error = web_cookie_auth_require(request, false, false, &view, &allowed);
|
||||
if (error != ESP_OK || !allowed) goto done;
|
||||
if (view.principal.role != USER_ROLE_ADMIN) {
|
||||
error = respond(request, "403 Forbidden", "{\"error\":\"admin_required\"}");
|
||||
goto done;
|
||||
}
|
||||
session_broker_management_snapshot_t snapshot;
|
||||
error = session_broker_get_management_snapshot(&snapshot);
|
||||
if (error != ESP_OK) {
|
||||
error = respond(request, "503 Service Unavailable", "{\"error\":\"broker_unavailable\"}");
|
||||
goto done;
|
||||
}
|
||||
/* Eight rows; names are exact bounded bytes as hex, never unescaped JSON.
|
||||
* Decimal-string drop counters retain all 64 bits in the browser. */
|
||||
char response[2048];
|
||||
int written = snprintf(response, sizeof(response),
|
||||
"{\"generation\":%" PRIu32 ",\"writer\":%" PRIu32 ",\"clients\":[",
|
||||
snapshot.generation, snapshot.writer_id);
|
||||
size_t used = 0;
|
||||
if (written < 0 || (size_t)written >= sizeof(response)) { error = ESP_FAIL; goto done; }
|
||||
used = (size_t)written;
|
||||
for (size_t i = 0; i < snapshot.count; ++i) {
|
||||
const session_broker_management_client_t *client = &snapshot.clients[i];
|
||||
char name[SESSION_BROKER_CLIENT_NAME_MAX * 2 + 1];
|
||||
static const char hex[] = "0123456789abcdef";
|
||||
size_t n = 0;
|
||||
for (; n < SESSION_BROKER_CLIENT_NAME_MAX && client->name[n]; ++n) {
|
||||
unsigned byte = (unsigned char)client->name[n];
|
||||
name[n * 2] = hex[byte >> 4]; name[n * 2 + 1] = hex[byte & 15];
|
||||
}
|
||||
name[n * 2] = 0;
|
||||
written = snprintf(response + used, sizeof(response) - used,
|
||||
"%s{\"id\":%" PRIu32 ",\"type\":%u,\"name_hex\":\"%s\",\"pending\":%u,\"high_water\":%u,\"dropped\":\"%" PRIu64 "\"}",
|
||||
i ? "," : "", client->id, (unsigned)client->type, name,
|
||||
(unsigned)client->pending, (unsigned)client->high_water, client->dropped);
|
||||
if (written < 0 || (size_t)written >= sizeof(response) - used) { error = ESP_FAIL; goto done; }
|
||||
used += (size_t)written;
|
||||
}
|
||||
written = snprintf(response + used, sizeof(response) - used, "]}");
|
||||
error = written < 0 || (size_t)written >= sizeof(response) - used ? ESP_FAIL :
|
||||
respond(request, "200 OK", response);
|
||||
done:
|
||||
secure_wipe(&view, sizeof(view));
|
||||
web_httpd_wipe_request(request, web_httpd_unread_body(request));
|
||||
return error;
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
/* SPDX-License-Identifier: GPL-3.0-only */
|
||||
#pragma once
|
||||
#include <stdint.h>
|
||||
#include "esp_http_server.h"
|
||||
|
||||
/* Optional admin-only snapshot and login-isolated typed assignment/results. */
|
||||
esp_err_t web_broker_settings_handler(httpd_req_t *request);
|
||||
esp_err_t web_broker_operation_handler(httpd_req_t *request);
|
||||
void web_broker_settings_execute(uint32_t id);
|
||||
+15
-1
@@ -27,6 +27,7 @@
|
||||
#include "web_account_settings.h"
|
||||
#include "web_network_settings.h"
|
||||
#include "web_display_settings.h"
|
||||
#include "web_broker_settings.h"
|
||||
#include "web_admin_transport.h"
|
||||
#include "web_session_store.h"
|
||||
#include "web_cookie_auth.h"
|
||||
@@ -414,6 +415,15 @@ static const httpd_uri_t s_account_generate_password_uri = {
|
||||
static const httpd_uri_t s_network_uri = {
|
||||
.uri = "/api/settings/network", .method = HTTP_GET, .handler = web_network_snapshot_handler,
|
||||
};
|
||||
static const httpd_uri_t s_broker_uri = {
|
||||
.uri = "/api/settings/broker", .method = HTTP_GET, .handler = web_broker_settings_handler,
|
||||
};
|
||||
static const httpd_uri_t s_broker_operation_get_uri = {
|
||||
.uri = "/api/settings/broker-operation", .method = HTTP_GET, .handler = web_broker_operation_handler,
|
||||
};
|
||||
static const httpd_uri_t s_broker_operation_post_uri = {
|
||||
.uri = "/api/settings/broker-operation", .method = HTTP_POST, .handler = web_broker_operation_handler,
|
||||
};
|
||||
static const httpd_uri_t s_display_uri = {
|
||||
.uri = "/api/settings/display", .method = HTTP_GET, .handler = web_display_settings_handler,
|
||||
};
|
||||
@@ -645,7 +655,7 @@ esp_err_t web_server_start(void)
|
||||
config.httpd.max_open_sockets = 6;
|
||||
config.httpd.max_uri_handlers =
|
||||
sizeof(s_uri_handlers) / sizeof(s_uri_handlers[0]) +
|
||||
sizeof(s_auth_uris) / sizeof(s_auth_uris[0]) + 16U;
|
||||
sizeof(s_auth_uris) / sizeof(s_auth_uris[0]) + 19U;
|
||||
/* Exhaustion rejects new sockets, never evicts an existing serial writer. */
|
||||
config.httpd.lru_purge_enable = false;
|
||||
config.httpd.recv_wait_timeout = 1;
|
||||
@@ -713,6 +723,10 @@ esp_err_t web_server_start(void)
|
||||
web_httpd_register_optional_get(server, &s_display_operation_get_uri) == ESP_OK &&
|
||||
web_httpd_register_optional(server, &s_display_operation_post_uri) != ESP_OK)
|
||||
(void)httpd_unregister_uri_handler(server, s_display_operation_get_uri.uri, HTTP_GET);
|
||||
if (web_httpd_register_optional_get(server, &s_broker_uri) == ESP_OK &&
|
||||
web_httpd_register_optional_get(server, &s_broker_operation_get_uri) == ESP_OK &&
|
||||
web_httpd_register_optional(server, &s_broker_operation_post_uri) != ESP_OK)
|
||||
(void)httpd_unregister_uri_handler(server, s_broker_operation_get_uri.uri, HTTP_GET);
|
||||
}
|
||||
if (error != ESP_OK) {
|
||||
web_cookie_auth_stop();
|
||||
|
||||
+161
-2
@@ -186,7 +186,8 @@ static const char s_index_html[] =
|
||||
"<div class=\"serial-actions\"><button id=\"settings-serial\" class=\"button\" type=\"button\" aria-pressed=\"true\">Serial settings</button>"
|
||||
"<button id=\"settings-accounts\" class=\"button\" type=\"button\" aria-pressed=\"false\">Accounts</button>"
|
||||
"<button id=\"settings-network\" class=\"button\" type=\"button\" aria-pressed=\"false\">Network</button>"
|
||||
"<button id=\"settings-display\" class=\"button\" type=\"button\" aria-pressed=\"false\">Display</button></div>"
|
||||
"<button id=\"settings-display\" class=\"button\" type=\"button\" aria-pressed=\"false\">Display</button><button id=\"settings-broker\" class=\"button\" type=\"button\" aria-pressed=\"false\">Broker</button></div>"
|
||||
"<div id=\"broker-settings\" hidden><h2>Broker clients and writer</h2><p class=\"connection-detail\">One writer, multiple isolated observers. Viewing, refreshing and selecting do not change the lease or either terminal. Assignment revokes the previous writer, without recalling bytes already accepted by UART. Any intervening lease transition rejects stale confirmation, even release and reacquire by the same writer.</p><p class=\"connection-detail\">Pending and high-water are bounded output bytes; dropped counts cover this connection or the last shell counter clear. No UART data is consumed. Refresh clears selection. No persistence or disconnect controls.</p><button id=\"broker-refresh\" class=\"button\" type=\"button\">Refresh</button><p id=\"broker-detail\" class=\"connection-detail\" role=\"status\"></p><dl id=\"broker-values\" class=\"settings-values\"></dl><div class=\"settings-edit\"><label>Assign writer to<select id=\"broker-target\"><option value=\"\">Select a connected client</option><option id=\"broker-option-0\" hidden disabled></option><option id=\"broker-option-1\" hidden disabled></option><option id=\"broker-option-2\" hidden disabled></option><option id=\"broker-option-3\" hidden disabled></option><option id=\"broker-option-4\" hidden disabled></option><option id=\"broker-option-5\" hidden disabled></option><option id=\"broker-option-6\" hidden disabled></option><option id=\"broker-option-7\" hidden disabled></option></select></label></div><div class=\"serial-actions\"><button id=\"broker-assign\" class=\"button\" type=\"button\">Assign writer…</button><button id=\"broker-result\" class=\"button\" type=\"button\">Check Operation Result</button></div><p id=\"broker-operation-detail\" class=\"connection-detail\" role=\"status\">Explicit confirmation required. Navigation or timeout does not cancel admitted work. Check Result after uncertainty; no automatic mutation retry.</p></div>\n"
|
||||
"<div id=\"display-settings\" hidden><h2>Display</h2>\n"
|
||||
"<p class=\"connection-detail\">Working OLED inactivity settings, not saved NVS values. Zero disables a transition. Each timeout is 0–86400 seconds; when both are enabled, Off must be later than Dim.</p>\n"
|
||||
"<p class=\"connection-detail\">Apply and Defaults change RAM only. Save persists the working snapshot, not browser drafts. Load discards drafts and uses stored settings, or defaults if storage is absent/incompatible; it does not change NVS. Reset saves defaults and applies them. Refresh discards drafts. Intervening configuration edits reject stale operations: Refresh and review before retrying.</p>\n"
|
||||
@@ -393,7 +394,7 @@ static const char s_app_js[] =
|
||||
" element('serial-result').disabled = busy;\n"
|
||||
"}\n"
|
||||
"function clearSettings() {\n"
|
||||
" clearAccounts(); clearNetwork(); clearDisplay();\n"
|
||||
" clearAccounts(); clearNetwork(); clearDisplay(); clearBroker();\n"
|
||||
" if (!serialAuto && serialOperationPending) element('serial-operation-detail').textContent = serialOutcomeWarning + 'Operation outcome pending or unknown. Select Check Result on return; navigation does not cancel backend work.';\n"
|
||||
" stopSerialAuto(true);\n"
|
||||
" if (settingsAbort) settingsAbort.abort();\n"
|
||||
@@ -408,6 +409,7 @@ static const char s_app_js[] =
|
||||
" if (settingsDomain === 'accounts') return refreshAccounts();\n"
|
||||
" if (settingsDomain === 'network') return refreshNetwork();\n"
|
||||
" if (settingsDomain === 'display') return refreshDisplay();\n"
|
||||
" if (settingsDomain === 'broker') return refreshBroker();\n"
|
||||
" if (selected !== 'settings' || accountRole !== 'admin' || !sessionVerified || suspended || unloading || navigating || loggingOut || settingsAbort || serialAuto) return;\n"
|
||||
" settingsHost.hidden = false;\n"
|
||||
" const controller = new AbortController(), generation = workGeneration; settingsAbort = controller;\n"
|
||||
@@ -638,6 +640,162 @@ static const char s_app_js[] =
|
||||
"element('display-refresh').addEventListener('click', refreshDisplay);\n"
|
||||
"element('display-result').addEventListener('click', () => displayOperation(null));\n"
|
||||
"for (const action of displayActions) element('display-' + action).addEventListener('click', () => displayOperation(action));\n"
|
||||
"const brokerDetail = element('broker-detail');\n"
|
||||
"let brokerClients = [], brokerWriter = 0;\n"
|
||||
"let brokerAbort = null, brokerGeneration = 0, brokerOperationAction = '';\n"
|
||||
"const brokerActions = ['assign'];\n"
|
||||
"const brokerUint = v => Number.isInteger(v) && v >= 0 && v <= 4294967295;\n"
|
||||
"function brokerValid(v) {\n"
|
||||
" return brokerUint(v.writer) && Array.isArray(v.clients) && v.clients.length <= 8 &&\n"
|
||||
" new Set(v.clients.map(c => c.id)).size === v.clients.length &&\n"
|
||||
" (!v.writer || v.clients.some(c => c.id === v.writer)) && v.clients.every(c =>\n"
|
||||
" c && Object.keys(c).length === 6 && brokerUint(c.id) && c.id > 0 &&\n"
|
||||
" Number.isInteger(c.type) && c.type >= 0 && c.type <= 4 &&\n"
|
||||
" typeof c.name_hex === 'string' && /^(?:[0-9a-f]{2}){0,23}$/.test(c.name_hex) &&\n"
|
||||
" Number.isInteger(c.pending) && c.pending >= 0 && c.pending <= 4096 &&\n"
|
||||
" Number.isInteger(c.high_water) && c.high_water >= c.pending && c.high_water <= 4096 &&\n"
|
||||
" typeof c.dropped === 'string' && /^(0|[1-9][0-9]{0,19})$/.test(c.dropped) && BigInt(c.dropped) <= 18446744073709551615n);\n"
|
||||
"}\n"
|
||||
"const brokerName = c => new TextDecoder().decode(Uint8Array.from(c.name_hex.match(/../g) || [], h => parseInt(h, 16)));\n"
|
||||
"const brokerLabel = c => String(c.id) + ' / ' + ['Console','USB','Web','SSH','Internal'][c.type] + ' / ' + brokerName(c);\n"
|
||||
"let brokerOperationId = 0, brokerOperationPending = false, brokerAwaitingAck = false;\n"
|
||||
"let brokerOutcomeWarning = '', brokerAuto = null;\n"
|
||||
"function stopBrokerAuto(recovery = false) {\n"
|
||||
" if (!brokerAuto) return;\n"
|
||||
" window.clearTimeout(brokerAuto.timer); window.clearTimeout(brokerAuto.deadline); brokerAuto = null;\n"
|
||||
" if (recovery) element('broker-operation-detail').textContent += ' Automatic checking stopped; outcome still uncertain. Select Check Result; do not resubmit.';\n"
|
||||
"}\n"
|
||||
"function expireBrokerAuto(auto) {\n"
|
||||
" if (brokerAuto !== auto) return;\n"
|
||||
" stopBrokerAuto(true);\n"
|
||||
" if (brokerAbort) brokerAbort.abort();\n"
|
||||
" brokerAbort = null; brokerButtons();\n"
|
||||
"}\n"
|
||||
"function scheduleBrokerCheck() {\n"
|
||||
" const auto = brokerAuto;\n"
|
||||
" if (!auto) return;\n"
|
||||
" if (auto.attempts >= 10) { stopBrokerAuto(true); brokerButtons(); return; }\n"
|
||||
" auto.timer = window.setTimeout(() => {\n"
|
||||
" if (brokerAuto !== auto) return;\n"
|
||||
" if (performance.now() >= auto.until) { expireBrokerAuto(auto); return; }\n"
|
||||
" ++auto.attempts; brokerOperation(null, true);\n"
|
||||
" }, 1000);\n"
|
||||
"}\n"
|
||||
"function startBrokerAuto() {\n"
|
||||
" const auto = {attempts: 0, timer: 0, deadline: 0, until: performance.now() + 15000}; brokerAuto = auto;\n"
|
||||
" auto.deadline = window.setTimeout(() => expireBrokerAuto(auto), 15000);\n"
|
||||
" scheduleBrokerCheck();\n"
|
||||
"}\n"
|
||||
"function brokerButtons() {\n"
|
||||
" const busy = !!brokerAbort || !!brokerAuto;\n"
|
||||
" const target = Number(element('broker-target').value);\n"
|
||||
" element('broker-assign').disabled = busy || brokerOperationPending || !brokerGeneration || brokerGeneration === 4294967295 || !brokerClients.some(c => c.id === target) || target === brokerWriter;\n"
|
||||
" element('broker-target').disabled = busy || brokerOperationPending || !brokerGeneration;\n"
|
||||
" element('broker-refresh').disabled = busy;\n"
|
||||
" element('broker-result').disabled = busy;\n"
|
||||
"}\n"
|
||||
"function clearBroker() {\n"
|
||||
" if (!brokerAuto && brokerOperationPending) element('broker-operation-detail').textContent = brokerOutcomeWarning + 'Outcome pending or unknown. Check Result on return; navigation does not cancel work.';\n"
|
||||
" stopBrokerAuto(true);\n"
|
||||
" if (brokerAbort) brokerAbort.abort(); brokerAbort = null; brokerGeneration = 0;\n"
|
||||
" brokerClients = []; brokerWriter = 0; element('broker-target').value = '';\n"
|
||||
" element('broker-values').textContent = '';\n"
|
||||
" for (let i = 0; i < 8; ++i) { const o = element('broker-option-' + i); o.textContent = ''; o.hidden = o.disabled = true; }\n"
|
||||
" brokerDetail.textContent = 'Select Refresh to read current values.'; brokerButtons();\n"
|
||||
"}\n"
|
||||
"async function refreshBroker() {\n"
|
||||
" if (settingsDomain !== 'broker' || selected !== 'settings' || accountRole !== 'admin' || !sessionVerified || suspended || unloading || navigating || loggingOut || brokerAbort || brokerAuto) return;\n"
|
||||
" const controller = new AbortController(), generation = workGeneration; brokerAbort = controller; brokerButtons();\n"
|
||||
" const current = () => brokerAbort === controller && selected === 'settings' && settingsDomain === 'broker';\n"
|
||||
" brokerDetail.textContent = 'Reading broker clients... Previous snapshot is stale until refreshed.';\n"
|
||||
" try {\n"
|
||||
" if (!await loadSession(generation, controller.signal, false) || !current()) return;\n"
|
||||
" const {status, payload: v} = await api('/api/settings/broker', generation, {signal: controller.signal, limit: 2048, current});\n"
|
||||
" if (status !== 200 || !v || Object.keys(v).length !== 3 || !Number.isInteger(v.generation) || v.generation < 1 || v.generation > 4294967295 || !brokerValid(v)) throw new Error('Invalid snapshot');\n"
|
||||
" brokerGeneration = v.generation; brokerClients = v.clients; brokerWriter = v.writer;\n"
|
||||
" element('broker-target').value = '';\n"
|
||||
" const list = element('broker-values'); list.textContent = '';\n"
|
||||
" for (let i = 0; i < 8; ++i) {\n"
|
||||
" const c = brokerClients[i], o = element('broker-option-' + i); o.hidden = o.disabled = !c;\n"
|
||||
" o.value = c ? String(c.id) : ''; o.textContent = c ? brokerLabel(c) : '';\n"
|
||||
" if (c) {\n"
|
||||
" const dt = document.createElement('dt'), dd = document.createElement('dd');\n"
|
||||
" dt.textContent = brokerLabel(c);\n"
|
||||
" dd.textContent = (c.id === brokerWriter ? 'Writer' : 'Observer') + ' — pending ' + c.pending + ' B; high-water ' + c.high_water + ' B; dropped ' + c.dropped + ' B';\n"
|
||||
" list.appendChild(dt); list.appendChild(dd);\n"
|
||||
" }\n"
|
||||
" }\n"
|
||||
" brokerDetail.textContent = (brokerOperationPending ? 'Outcome pending or unknown. ' : '') + 'Writer: ' + (brokerWriter || 'None') + '. ' + brokerClients.length + ' connected clients. Refresh clears selection; review and explicitly confirm assignment.' + (brokerGeneration === 4294967295 ? ' Confirmation generation exhausted; use the admin shell.' : '');\n"
|
||||
" } catch (error) {\n"
|
||||
" if (live(generation) && current()) { brokerGeneration = 0; brokerDetail.textContent = (error.status ? error.message : 'Broker snapshot unavailable or invalid.') + ' Select Refresh to retry.'; }\n"
|
||||
" } finally { if (current()) { brokerAbort = null; brokerButtons(); } }\n"
|
||||
"}\n"
|
||||
"async function brokerOperation(action, automatic = false) {\n"
|
||||
" if (settingsDomain !== 'broker') return;\n"
|
||||
" if (selected !== 'settings' || accountRole !== 'admin' || !sessionVerified || suspended || unloading || navigating || loggingOut || brokerAbort || (action && brokerOperationPending)) return;\n"
|
||||
" if (!automatic && brokerAuto) return;\n"
|
||||
" const detail = element('broker-operation-detail');\n"
|
||||
" let refresh = false, poll = false;\n"
|
||||
" let body;\n"
|
||||
" if (action) {\n"
|
||||
" const target = Number(element('broker-target').value), client = brokerClients.find(c => c.id === target);\n"
|
||||
" if (action !== 'assign' || !client || !brokerGeneration || brokerGeneration === 4294967295 || target === brokerWriter) return;\n"
|
||||
" const value = {action, generation: brokerGeneration, target};\n"
|
||||
" if (!window.confirm('Assign the writer lease to ' + brokerLabel(client) + '? Current writer: ' + (brokerWriter || 'None') + '. The previous writer becomes an observer. Already queued UART bytes are not recalled.')) return;\n"
|
||||
" body = JSON.stringify(value);\n"
|
||||
" if (new TextEncoder().encode(body).length > 256) return;\n"
|
||||
" }\n"
|
||||
" const controller = new AbortController(), generation = workGeneration; brokerAbort = controller;\n"
|
||||
" const auto = automatic ? brokerAuto : null;\n"
|
||||
" const current = () => {\n"
|
||||
" if (auto && brokerAuto === auto && performance.now() >= auto.until) expireBrokerAuto(auto);\n"
|
||||
" return brokerAbort === controller && selected === 'settings' && settingsDomain === 'broker';\n"
|
||||
" };\n"
|
||||
" element('broker-refresh').disabled = true; brokerButtons();\n"
|
||||
" detail.textContent = brokerOutcomeWarning + (action ? 'Assigning... Submitting once; completion will be checked automatically.' : 'Reading latest result for this login...');\n"
|
||||
" brokerDetail.textContent = 'Snapshot stale: operation pending or outcome not yet checked.';\n"
|
||||
" try {\n"
|
||||
" if (!await loadSession(generation, controller.signal, false)) throw new Error('Session check cancelled');\n"
|
||||
" if (!current()) return;\n"
|
||||
" if (action) { brokerOperationPending = true; brokerAwaitingAck = true; }\n"
|
||||
" const {status, payload: result} = await api('/api/settings/broker-operation', generation, {method: action ? 'POST' : 'GET', body, signal: controller.signal, limit: 96, current});\n"
|
||||
" if (status !== (action ? 202 : 200) || !result || Object.keys(result).length !== 3 || !Number.isInteger(result.id) || result.id < 0 || result.id > 4294967295 ||\n"
|
||||
" !['none', ...brokerActions].includes(result.action) || !['idle','pending','ok','failed','cancelled','conflict'].includes(result.state) ||\n"
|
||||
" ((result.id === 0) !== (result.state === 'idle')) || ((result.id === 0) !== (result.action === 'none')) ||\n"
|
||||
" (action && (!result.id || result.action !== action || result.state !== 'pending'))) throw new Error('Invalid operation response');\n"
|
||||
" if (!action && brokerOperationId && brokerOperationId === result.id && brokerOperationAction && brokerOperationAction !== result.action) throw new Error('Operation action changed for the same ID');\n"
|
||||
" const uncertain = !action && brokerAwaitingAck;\n"
|
||||
" const replaced = !action && brokerOperationId && brokerOperationId !== result.id;\n"
|
||||
" if (action) brokerOutcomeWarning = '';\n"
|
||||
" else if (uncertain) brokerOutcomeWarning = 'Submission acknowledgement was lost; this latest result may belong to an earlier operation or another tab. Inspect before retrying. ';\n"
|
||||
" else if (replaced) brokerOutcomeWarning = 'Previous result was replaced or unavailable; its outcome is unknown. ';\n"
|
||||
" brokerOperationId = result.id; brokerOperationAction = result.action; brokerOperationPending = result.state === 'pending'; brokerAwaitingAck = false;\n"
|
||||
" const messages = {idle: 'No retained result. Outcome may be unknown; refresh clients and inspect the current writer before retrying.',\n"
|
||||
" pending: 'Pending: queued or executing; do not resubmit. Automatic checks are bounded; Check Result is available for recovery.',\n"
|
||||
" ok: 'Assignment completed at execution time. Another client may subsequently change the lease; refresh to inspect.',\n"
|
||||
" failed: 'Assignment failed; refresh and inspect before retrying.',\n"
|
||||
" conflict: 'Writer lease changed, target disconnected, or broker unavailable. No lease change by this operation. Refresh, reselect and confirm again.',\n"
|
||||
" cancelled: 'Operation rejected before execution because the login or queue deadline was no longer current.'};\n"
|
||||
" detail.textContent = brokerOutcomeWarning + result.action + ': ' + messages[result.state];\n"
|
||||
" poll = !replaced && result.state === 'pending' && (!!action || automatic);\n"
|
||||
" refresh = !action && result.state !== 'pending' && result.state !== 'idle';\n"
|
||||
" } catch (error) {\n"
|
||||
" if (live(generation) && current()) detail.textContent = brokerOutcomeWarning + (error.status ? error.message : 'Operation outcome unknown.') + ' Check Result and Refresh before any explicit retry. No automatic retry.';\n"
|
||||
" } finally {\n"
|
||||
" if (current()) {\n"
|
||||
" brokerAbort = null;\n"
|
||||
" if (poll) { if (action) startBrokerAuto(); else scheduleBrokerCheck(); }\n"
|
||||
" else stopBrokerAuto();\n"
|
||||
" brokerButtons();\n"
|
||||
" if (refresh) await refreshBroker();\n"
|
||||
" }\n"
|
||||
" }\n"
|
||||
"}\n"
|
||||
"element('settings-broker').addEventListener('click', () => selectSettingsDomain('broker'));\n"
|
||||
"element('broker-refresh').addEventListener('click', refreshBroker);\n"
|
||||
"element('broker-result').addEventListener('click', () => brokerOperation(null));\n"
|
||||
"for (const action of brokerActions) element('broker-' + action).addEventListener('click', () => brokerOperation(action));\n"
|
||||
"element('broker-target').addEventListener('change', brokerButtons);\n"
|
||||
"let settingsDomain = 'serial', accounts = [], accountsAbort = null, accountId = 0, accountPending = false, accountAwaitingAck = false, accountWarning = '';\n"
|
||||
"let keysAbort = null, accountKeys = [], keysIdentity = '';\n"
|
||||
"function keyIdentity() { const t = accounts[Number(element('account-target').value)]; return t ? JSON.stringify([t.username,t.user_id,t.auth_generation]) : ''; }\n"
|
||||
@@ -717,6 +875,7 @@ static const char s_app_js[] =
|
||||
" clearSettings(); settingsDomain = domain; settingsHost.hidden = false;\n"
|
||||
" element('serial-settings-content').hidden = domain !== 'serial'; element('account-settings').hidden = domain !== 'accounts'; element('network-settings').hidden = domain !== 'network';\n"
|
||||
" element('display-settings').hidden = domain !== 'display'; element('settings-display').setAttribute('aria-pressed', String(domain === 'display'));\n"
|
||||
" element('broker-settings').hidden = domain !== 'broker'; element('settings-broker').setAttribute('aria-pressed', String(domain === 'broker'));\n"
|
||||
" element('settings-network').setAttribute('aria-pressed', String(domain === 'network'));\n"
|
||||
" element('settings-serial').setAttribute('aria-pressed', String(domain === 'serial')); element('settings-accounts').setAttribute('aria-pressed', String(domain === 'accounts'));\n"
|
||||
" refreshSettings();\n"
|
||||
|
||||
Reference in New Issue
Block a user